diff --git a/src/box/errcode.h b/src/box/errcode.h
index 1523b1997158a38b169d7fb1fefdae9aff79c4c7..a1fcf0121d298c24ac6a8ef66d61a6755e594804 100644
--- a/src/box/errcode.h
+++ b/src/box/errcode.h
@@ -230,6 +230,7 @@ struct errcode_record {
 	/*175 */_(ER_SQL_NO_SUCH_PRAGMA,	"Pragma '%s' does not exist") \
 	/*176 */_(ER_SQL_CANT_RESOLVE_FIELD,	"Can’t resolve field '%s'") \
 	/*177 */_(ER_INDEX_EXISTS_IN_SPACE,	"Index '%s' already exists in space '%s'") \
+	/*178 */_(ER_INCONSISTENT_TYPES,	"Inconsistent types: expected %s got %s") \
 
 /*
  * !IMPORTANT! Please follow instructions at start of the file
diff --git a/src/box/sql/vdbe.c b/src/box/sql/vdbe.c
index 4ca5bc23b888f9068a82d995225c8ea2fd7090b6..7499bbd98cfe43ce7a42540fba94c587948435a8 100644
--- a/src/box/sql/vdbe.c
+++ b/src/box/sql/vdbe.c
@@ -619,6 +619,32 @@ vdbe_add_new_autoinc_id(struct Vdbe *vdbe, int64_t id)
 	return 0;
 }
 
+/**
+ * Simple type to str convertor. It is used to simplify
+ * error reporting.
+ */
+static char *
+mem_type_to_str(const struct Mem *p)
+{
+	assert(p != NULL);
+	switch (p->flags & MEM_PURE_TYPE_MASK) {
+	case MEM_Null:
+		return "NULL";
+	case MEM_Str:
+		return "TEXT";
+	case MEM_Int:
+		return "INTEGER";
+	case MEM_Real:
+		return "REAL";
+	case MEM_Blob:
+		return "BLOB";
+	case MEM_Bool:
+		return "BOOLEAN";
+	default:
+		unreachable();
+	}
+}
+
 /*
  * Execute as much of a VDBE program as we can.
  * This is the core of sql_step().
@@ -1515,6 +1541,9 @@ case OP_ResultRow: {
  * It is illegal for P1 and P3 to be the same register. Sometimes,
  * if P3 is the same register as P2, the implementation is able
  * to avoid a memcpy().
+ *
+ * Concatenation operator accepts only arguments of string-like
+ * types (i.e. TEXT and BLOB).
  */
 case OP_Concat: {           /* same as TK_CONCAT, in1, in2, out3 */
 	i64 nByte;
@@ -1527,9 +1556,30 @@ case OP_Concat: {           /* same as TK_CONCAT, in1, in2, out3 */
 		sqlVdbeMemSetNull(pOut);
 		break;
 	}
+	/*
+	 * Concatenation operation can be applied only to
+	 * strings and blobs.
+	 */
+	uint32_t str_type_p1 = pIn1->flags & (MEM_Blob | MEM_Str);
+	uint32_t str_type_p2 = pIn2->flags & (MEM_Blob | MEM_Str);
+	if (str_type_p1 == 0 || str_type_p2 == 0) {
+		char *inconsistent_type = str_type_p1 == 0 ?
+					  mem_type_to_str(pIn1) :
+					  mem_type_to_str(pIn2);
+		diag_set(ClientError, ER_INCONSISTENT_TYPES, "TEXT or BLOB",
+			 inconsistent_type);
+		rc = SQL_TARANTOOL_ERROR;
+		goto abort_due_to_error;
+	}
+
+	/* Moreover, both operands must be of the same type. */
+	if (str_type_p1 != str_type_p2) {
+		diag_set(ClientError, ER_INCONSISTENT_TYPES,
+			 mem_type_to_str(pIn2), mem_type_to_str(pIn1));
+		rc = SQL_TARANTOOL_ERROR;
+		goto abort_due_to_error;
+	}
 	if (ExpandBlob(pIn1) || ExpandBlob(pIn2)) goto no_mem;
-	Stringify(pIn1);
-	Stringify(pIn2);
 	nByte = pIn1->n + pIn2->n;
 	if (nByte>db->aLimit[SQL_LIMIT_LENGTH]) {
 		goto too_big;
diff --git a/test/box/misc.result b/test/box/misc.result
index 80bde72ea25ed8e6d4216ae9f1ac81f2b068df4c..d9f5dd5abec8bc97bec4276cb7a8d770deb8f911 100644
--- a/test/box/misc.result
+++ b/test/box/misc.result
@@ -507,6 +507,7 @@ t;
   175: box.error.SQL_NO_SUCH_PRAGMA
   176: box.error.SQL_CANT_RESOLVE_FIELD
   177: box.error.INDEX_EXISTS_IN_SPACE
+  178: box.error.INCONSISTENT_TYPES
 ...
 test_run:cmd("setopt delimiter ''");
 ---
diff --git a/test/sql-tap/autoinc.test.lua b/test/sql-tap/autoinc.test.lua
index 1110a24720489d965011eab51caa30320159fe03..5ec9fd63286a67f1ea62c9cc6c27718a933f407c 100755
--- a/test/sql-tap/autoinc.test.lua
+++ b/test/sql-tap/autoinc.test.lua
@@ -686,11 +686,11 @@ test:do_test(
             DROP TRIGGER t3928r2;
             CREATE TRIGGER t3928r3 BEFORE UPDATE ON t3928
               WHEN new.b=='456' BEGIN
-                 INSERT INTO t3928(b) VALUES('before-int-' || new.b);
+                 INSERT INTO t3928(b) VALUES('before-int-' || CAST(new.b AS TEXT));
             END;
             CREATE TRIGGER t3928r4 AFTER UPDATE ON t3928
               WHEN new.b=='456' BEGIN
-                 INSERT INTO t3928(b) VALUES('after-int-' || new.b);
+                 INSERT INTO t3928(b) VALUES('after-int-' || CAST(new.b AS TEXT));
             END;
             DELETE FROM t3928 WHERE a!=1;
             UPDATE t3928 SET b=456 WHERE a=1;
@@ -725,12 +725,12 @@ test:do_test(
             DELETE FROM t3928;
             CREATE TABLE t3928c(y INTEGER PRIMARY KEY AUTOINCREMENT, z TEXT);
             CREATE TRIGGER t3928br1 BEFORE DELETE ON t3928b BEGIN
-              INSERT INTO t3928(b) VALUES('before-del-'||old.x);
-              INSERT INTO t3928c(z) VALUES('before-del-'||old.x);
+              INSERT INTO t3928(b) VALUES('before-del-'|| CAST(old.x AS TEXT));
+              INSERT INTO t3928c(z) VALUES('before-del-'|| CAST(old.x AS TEXT));
             END;
             CREATE TRIGGER t3928br2 AFTER DELETE ON t3928b BEGIN
-              INSERT INTO t3928(b) VALUES('after-del-'||old.x);
-              INSERT INTO t3928c(z) VALUES('after-del-'||old.x);
+              INSERT INTO t3928(b) VALUES('after-del-'|| CAST(old.x AS TEXT));
+              INSERT INTO t3928c(z) VALUES('after-del-'|| CAST(old.x AS TEXT));
             END;
             DELETE FROM t3928b;
             SELECT * FROM t3928 ORDER BY a;
diff --git a/test/sql-tap/e_select1.test.lua b/test/sql-tap/e_select1.test.lua
index bd9852899f96c990623c4ae96365d720517511e1..75fe81e0a1039717d17b1c662288f478d0ce3971 100755
--- a/test/sql-tap/e_select1.test.lua
+++ b/test/sql-tap/e_select1.test.lua
@@ -875,7 +875,7 @@ test:do_select_tests(
                 0, -22.18, -2.2, -23.18, "suiters", 1, 68, "", 67, "quartets", 0, -31.3,
                 -1.04, -32.3, "aspen", 0, 1, 63, 0, "-26"}},
 
-        {"3", "SELECT 32*32, d||e FROM z2", {1024, "", 1024, "36.06.0"}},
+        {"3", "SELECT 32*32, CAST(d AS TEXT) || CAST(e AS TEXT) FROM z2", {1024, "", 1024, "36.06.0"}},
     })
 
 -- Test cases e_select-4.5.* and e_select-4.6.* together show that:
@@ -1136,7 +1136,7 @@ test:do_select_tests(
         {"1.1", "SELECT up FROM c1 GROUP BY up HAVING count(*)>3", {"x"}},
         {"1.2", "SELECT up FROM c1 GROUP BY up HAVING sum(down)>16", {"y"}},
         {"1.3", "SELECT up FROM c1 GROUP BY up HAVING sum(down)<16", {"x"}},
-        {"1.4", "SELECT up||down FROM c1 GROUP BY (down<5) HAVING max(down)<10", {"x4"}},
+        {"1.4", "SELECT up|| CAST(down AS TEXT) FROM c1 GROUP BY (down<5) HAVING max(down)<10", {"x4"}},
 
         {"2.1", "SELECT up FROM c1 GROUP BY up HAVING down>10", {"y"}},
         {"2.2", "SELECT up FROM c1 GROUP BY up HAVING up='y'", {"y"}},
diff --git a/test/sql-tap/func.test.lua b/test/sql-tap/func.test.lua
index 2336fe1cc9efb1a0cabdb63fb1990162747d9989..815a567239398ce60718f16607d332bcde7f7051 100755
--- a/test/sql-tap/func.test.lua
+++ b/test/sql-tap/func.test.lua
@@ -475,7 +475,7 @@ test:do_catchsql_test(
 test:do_catchsql_test(
     "func-4.10",
     [[
-        SELECT 'x' || round(c,a) || 'y' FROM t1 ORDER BY a
+        SELECT 'x' || CAST(round(c,a) AS TEXT) || 'y' FROM t1 ORDER BY a
     ]], {
         -- <func-4.10>
         0, {"x3.0y", "x-12345.68y", "x-5.0y"}
@@ -863,7 +863,7 @@ test:do_test(
 test:do_execsql_test(
     "func-8.2",
     [[
-        SELECT max('z+'||a||'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP') FROM t2;
+        SELECT max('z+'|| CAST(a AS TEXT) ||'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP') FROM t2;
     ]], {
         -- <func-8.2>
         "z+67890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP"
diff --git a/test/sql-tap/sort.test.lua b/test/sql-tap/sort.test.lua
index df666070f33257c5aa607909803676328dd25f08..a84c549ccb17382aa84cdc6fa5a1eb7cb5a4e1a8 100755
--- a/test/sql-tap/sort.test.lua
+++ b/test/sql-tap/sort.test.lua
@@ -238,7 +238,7 @@ test:do_execsql_test(
 test:do_execsql_test(
     "sort-2.1.1",
     [[
-        UPDATE t1 SET v='x' || -flt;
+        UPDATE t1 SET v='x' || CAST(-flt AS TEXT);
         UPDATE t1 SET v='x-2b' where v=='x-0.123';
         SELECT v FROM t1 ORDER BY v;
     ]], {
@@ -338,7 +338,7 @@ test:do_execsql_test(
 test:do_execsql_test(
     "sort-4.2",
     [[
-        SELECT n||'' FROM t1 ORDER BY 1;
+        SELECT CAST(n AS TEXT) || '' FROM t1 ORDER BY 1;
     ]], {
         -- <sort-4.2>
         "1", "10", "11", "12", "2", "3", "4", "5", "6", "7", "8", "9"
@@ -358,7 +358,7 @@ test:do_execsql_test(
 test:do_execsql_test(
     "sort-4.4",
     [[
-        SELECT n||'' FROM t1 ORDER BY 1 DESC;
+        SELECT CAST(n AS TEXT) || '' FROM t1 ORDER BY 1 DESC;
     ]], {
         -- <sort-4.4>
         "9", "8", "7", "6", "5", "4", "3", "2", "12", "11", "10", "1"
diff --git a/test/sql-tap/tkt2192.test.lua b/test/sql-tap/tkt2192.test.lua
index eb53863d4d46d925e91b439a91ad610a028429ce..b118d6310b144147d3e79321586887e0a6f57051 100755
--- a/test/sql-tap/tkt2192.test.lua
+++ b/test/sql-tap/tkt2192.test.lua
@@ -146,7 +146,7 @@ test:do_execsql_test(
 test:do_execsql_test(
     "tkt2192-2.3",
     [[
-        SELECT x.a || '/' || x.b || '/' || y.b
+        SELECT CAST(x.a AS TEXT) || '/' || CAST(x.b AS TEXT) || '/' || CAST(y.b AS TEXT)
           FROM v1 AS x JOIN v1 AS y ON x.a=y.a AND x.b<y.b
          ORDER BY x.a, x.b, y.b
     ]], {
@@ -159,7 +159,7 @@ test:do_execsql_test(
     "tkt2192-2.4",
     [[
         CREATE VIEW v2 AS
-        SELECT x.a || '/' || x.b || '/' || y.b AS z
+        SELECT CAST(x.a AS TEXT) || '/' || CAST(x.b AS TEXT) || '/' || CAST(y.b AS TEXT) AS z
           FROM v1 AS x JOIN v1 AS y ON x.a=y.a AND x.b<y.b
          ORDER BY x.a, x.b, y.b;
         SELECT * FROM v2;
diff --git a/test/sql-tap/trigger5.test.lua b/test/sql-tap/trigger5.test.lua
index 8d6fd83393bdb52fb23d9bfbf6dc877fe4e2c102..f71649925e005c4510c623e22cbaf779261e5391 100755
--- a/test/sql-tap/trigger5.test.lua
+++ b/test/sql-tap/trigger5.test.lua
@@ -30,8 +30,8 @@ test:do_execsql_test(
         BEGIN
           INSERT INTO Undo VALUES
              ((SELECT coalesce(max(id),0) + 1 FROM Undo),
-              (SELECT 'INSERT INTO Item (a,b,c) VALUES (' || coalesce(old.a,'NULL')
-                  || ',' || quote(old.b) || ',' || old.c || ');'));
+              (SELECT 'INSERT INTO Item (a,b,c) VALUES (' || CAST(coalesce(old.a,'NULL') AS TEXT)
+                  || ',' || quote(old.b) || ',' || CAST(old.c AS TEXT) || ');'));
         END;
         DELETE FROM Item WHERE a = 1;
         SELECT * FROM Undo;
diff --git a/test/sql/types.result b/test/sql/types.result
index 9043cbfd507bd2e733a563b6e6da86dc26549d32..0cbbd164227b1e62928b33bb729b7d481307dc49 100644
--- a/test/sql/types.result
+++ b/test/sql/types.result
@@ -131,3 +131,35 @@ box.sql.execute("SELECT * FROM test")
 sp:drop()
 ---
 ...
+-- gh-3544: concatenation operator accepts only TEXT and BLOB.
+--
+box.sql.execute("SELECT 'abc' || 1;")
+---
+- error: 'Inconsistent types: expected TEXT or BLOB got INTEGER'
+...
+box.sql.execute("SELECT 'abc' || 1.123;")
+---
+- error: 'Inconsistent types: expected TEXT or BLOB got REAL'
+...
+box.sql.execute("SELECT 1 || 'abc';")
+---
+- error: 'Inconsistent types: expected TEXT or BLOB got INTEGER'
+...
+box.sql.execute("SELECT 1.123 || 'abc';")
+---
+- error: 'Inconsistent types: expected TEXT or BLOB got REAL'
+...
+box.sql.execute("SELECt 'a' || 'b' || 1;")
+---
+- error: 'Inconsistent types: expected TEXT or BLOB got INTEGER'
+...
+-- What is more, they must be of the same type.
+--
+box.sql.execute("SELECT 'abc' || CAST('x' AS BLOB);")
+---
+- error: 'Inconsistent types: expected TEXT got BLOB'
+...
+box.sql.execute("SELECT CAST('abc' AS BLOB) || 'x';")
+---
+- error: 'Inconsistent types: expected BLOB got TEXT'
+...
diff --git a/test/sql/types.test.lua b/test/sql/types.test.lua
index 8eda3615984a05a256bb03a982cd8345e204d361..e0331bb8f7b0f018b3b328e6dadb7d539101af5c 100644
--- a/test/sql/types.test.lua
+++ b/test/sql/types.test.lua
@@ -40,3 +40,15 @@ sp:insert({1, true})
 sp:insert({2, false})
 box.sql.execute("SELECT * FROM test")
 sp:drop()
+
+-- gh-3544: concatenation operator accepts only TEXT and BLOB.
+--
+box.sql.execute("SELECT 'abc' || 1;")
+box.sql.execute("SELECT 'abc' || 1.123;")
+box.sql.execute("SELECT 1 || 'abc';")
+box.sql.execute("SELECT 1.123 || 'abc';")
+box.sql.execute("SELECt 'a' || 'b' || 1;")
+-- What is more, they must be of the same type.
+--
+box.sql.execute("SELECT 'abc' || CAST('x' AS BLOB);")
+box.sql.execute("SELECT CAST('abc' AS BLOB) || 'x';")