diff --git a/src/box/errcode.h b/src/box/errcode.h
index d68148a66fbc1cd0d9aac8f4cc7dcf5255916216..c9bca47f8b15801e0e6d29e07c97139f1ba91fc2 100644
--- a/src/box/errcode.h
+++ b/src/box/errcode.h
@@ -76,7 +76,7 @@ struct errcode_record {
 	/* 21 */_(ER_PROC_RET,			"msgpack.encode: can not encode Lua type '%s'") \
 	/* 22 */_(ER_TUPLE_NOT_ARRAY,		"Tuple/Key must be MsgPack array") \
 	/* 23 */_(ER_FIELD_TYPE,		"Tuple field %u type does not match one required by operation: expected %s") \
-	/* 24 */_(ER_FIELD_TYPE_MISMATCH,	"Ambiguous field type in index '%s', key part %u. Requested type is %s but the field has previously been defined as %s") \
+	/* 24 */_(ER_FIELD_TYPE_MISMATCH,	"Ambiguous field type in index '%s', field %u. Requested type is %s but the field has previously been defined as %s") \
 	/* 25 */_(ER_SPLICE,			"SPLICE error on field %u: %s") \
 	/* 26 */_(ER_UPDATE_ARG_TYPE,		"Argument type in operation '%c' on field %u does not match field type: expected %s") \
 	/* 27 */_(ER_TUPLE_IS_TOO_LONG,		"Tuple is too long %u") \
diff --git a/src/box/key_def.h b/src/box/key_def.h
index 7eb82ff8911bdcbbbc3fb03dd93020d7c13df81d..bebe0b5a2bcbbec29ae163c5e08468cff1228e95 100644
--- a/src/box/key_def.h
+++ b/src/box/key_def.h
@@ -246,8 +246,9 @@ struct key_def {
 	/** Index type. */
 	enum index_type type;
 	struct key_opts opts;
-	/** comparators */
+	/** tuple <-> tuple comparison function */
 	tuple_compare_t tuple_compare;
+	/** tuple <-> key comparison function */
 	tuple_compare_with_key_t tuple_compare_with_key;
 	/** The size of the 'parts' array. */
 	uint32_t part_count;
@@ -454,6 +455,24 @@ int
 key_validate_parts(struct key_def *key_def, const char *key,
 		   uint32_t part_count);
 
+/**
+ * Return true if @a key_def defines a sequential key without
+ * holes starting from the first field. In other words, for all
+ * key parts key_def->parts[part_id].fieldno == part_id.
+ * @param key_def key_def
+ * @retval true key_def is sequential
+ * @retval false otherwise
+ */
+static inline bool
+key_def_is_sequential(const struct key_def *key_def)
+{
+	for (uint32_t part_id = 0; part_id < key_def->part_count; part_id++) {
+		if (key_def->parts[part_id].fieldno != part_id)
+			return false;
+	}
+	return true;
+}
+
 /** A helper table for key_mp_type_validate */
 extern const uint32_t key_mp_type[];
 
diff --git a/src/box/tuple_compare.cc b/src/box/tuple_compare.cc
index 9906381628687018bd21767cdcf6123422484525..905170e81bda48d151c256039ffd342ca06acaf2 100644
--- a/src/box/tuple_compare.cc
+++ b/src/box/tuple_compare.cc
@@ -253,22 +253,15 @@ tuple_compare_slowpath(const struct tuple *tuple_a, const struct tuple *tuple_b,
 					  tuple_field_map(tuple_b), key_def);
 }
 
-int
-key_compare(const char *key_a, uint32_t part_count_a,
-	    const char *key_b, uint32_t part_count_b,
-	    const struct key_def *key_def)
+static inline int
+key_compare_parts(const char *key_a, const char *key_b, uint32_t part_count,
+		  const struct key_part *parts)
 {
-	assert(key_a != NULL || part_count_a == 0);
-	assert(key_b != NULL || part_count_b == 0);
-	assert(part_count_a <= key_def->part_count);
-	assert(part_count_b <= key_def->part_count);
-	const struct key_part *part = key_def->parts;
-	uint32_t part_count;
-	part_count = MIN(MIN(part_count_a, key_def->part_count), part_count_b);
+	assert((key_a != NULL && key_b != NULL) || part_count == 0);
+	const struct key_part *part = parts;
 	if (likely(part_count == 1))
 		return tuple_compare_field(key_a, key_b, part->type);
-	const struct key_part *end;
-	end = part + part_count;
+	const struct key_part *end = part + part_count;
 	int r = 0; /* Part count can be 0 in wildcard searches. */
 	for (; part < end; part++) {
 		r = tuple_compare_field(key_a, key_b, part->type);
@@ -280,6 +273,50 @@ key_compare(const char *key_a, uint32_t part_count_a,
 	return r;
 }
 
+int
+key_compare(const char *key_a, uint32_t part_count_a,
+	    const char *key_b, uint32_t part_count_b,
+	    const struct key_def *key_def)
+{
+	assert(part_count_a <= key_def->part_count);
+	assert(part_count_b <= key_def->part_count);
+	uint32_t part_count = MIN(part_count_a, part_count_b);
+	assert(part_count <= key_def->part_count);
+	return key_compare_parts(key_a, key_b, part_count, key_def->parts);
+}
+
+static int
+tuple_compare_with_key_sequential(const struct tuple *tuple, const char *key,
+				  uint32_t part_count,
+				  const struct key_def *key_def)
+{
+	assert(key_def_is_sequential(key_def));
+	const char *tuple_key = tuple_data(tuple);
+	uint32_t tuple_field_count = mp_decode_array(&tuple_key);
+	assert(tuple_field_count >= key_def->part_count);
+	assert(part_count <= key_def->part_count);
+	(void) tuple_field_count;
+	return key_compare_parts(tuple_key, key, part_count, key_def->parts);
+}
+
+static int
+tuple_compare_sequential(const struct tuple *tuple_a,
+			 const struct tuple *tuple_b,
+			 const struct key_def *key_def)
+{
+	assert(key_def_is_sequential(key_def));
+	const char *key_a = tuple_data(tuple_a);
+	uint32_t field_count_a = mp_decode_array(&key_a);
+	assert(field_count_a >= key_def->part_count);
+	(void) field_count_a;
+	const char *key_b = tuple_data(tuple_b);
+	uint32_t field_count_b = mp_decode_array(&key_b);
+	assert(field_count_b >= key_def->part_count);
+	(void) field_count_b;
+	return key_compare_parts(key_a, key_b, key_def->part_count,
+				 key_def->parts);
+}
+
 static inline int
 tuple_compare_with_key_slowpath_raw(const struct tuple_format *format,
 				    const char *tuple, const uint32_t *field_map,
@@ -511,6 +548,8 @@ tuple_compare_create(const struct key_def *def) {
 		if (i == def->part_count && cmp_arr[k].p[i * 2] == UINT32_MAX)
 			return cmp_arr[k].f;
 	}
+	if (key_def_is_sequential(def))
+		return tuple_compare_sequential;
 	return tuple_compare_slowpath;
 }
 
@@ -702,16 +741,16 @@ tuple_compare_with_key_create(const struct key_def *def)
 
 		uint32_t i = 0;
 		for (; i < def->part_count; i++) {
-
 			if (def->parts[i].fieldno != cmp_wk_arr[k].p[i * 2] ||
 			    def->parts[i].type != cmp_wk_arr[k].p[i * 2 + 1]) {
-
 				break;
 			}
 		}
 		if (i == def->part_count)
 			return cmp_wk_arr[k].f;
 	}
+	if (key_def_is_sequential(def))
+		return tuple_compare_with_key_sequential;
 	return tuple_compare_with_key_slowpath;
 }
 
diff --git a/src/box/tuple_compare.h b/src/box/tuple_compare.h
index 7c382ce67718ad72c24900d99d67197a93a4aab3..d146347693391bf1a152352d641e059404aed306 100644
--- a/src/box/tuple_compare.h
+++ b/src/box/tuple_compare.h
@@ -32,6 +32,7 @@
  */
 #include <stddef.h>
 #include <stdint.h>
+#include <stdbool.h>
 
 #include "key_def.h"
 
diff --git a/src/box/tuple_format.c b/src/box/tuple_format.c
index 2bcdb5d617c3b3f611d19d68c538c8cf744ae138..dfec11c035b9b813f293175e24b41f8eea88f791 100644
--- a/src/box/tuple_format.c
+++ b/src/box/tuple_format.c
@@ -42,29 +42,54 @@ static uint32_t formats_size = 0, formats_capacity = 0;
 static int
 field_type_create(struct tuple_format *format, struct rlist *key_list)
 {
+	if (format->field_count == 0)
+		return 0;
+
 	/* There may be fields between indexed fields (gaps). */
-	for (uint32_t i = 0; i < format->field_count; i++)
+	for (uint32_t i = 0; i < format->field_count; i++) {
 		format->fields[i].type = FIELD_TYPE_ANY;
+		format->fields[i].offset_slot = TUPLE_OFFSET_SLOT_MISSING;
+	}
+
+	int current_slot = 0;
 
 	struct key_def *key_def;
 	/* extract field type info */
 	rlist_foreach_entry(key_def, key_list, link) {
-		for (uint32_t i = 0; i < key_def->part_count; i++) {
-			assert(key_def->parts[i].fieldno < format->field_count);
-			enum field_type set_type = key_def->parts[i].type;
-			enum field_type *fmt_type =
-				&format->fields[key_def->parts[i].fieldno].type;
-			if (*fmt_type != FIELD_TYPE_ANY &&
-			    *fmt_type != set_type) {
+		bool is_sequential = key_def_is_sequential(key_def);
+		const struct key_part *part = key_def->parts;
+		const struct key_part *parts_end = part + key_def->part_count;
+		for (; part < parts_end; part++) {
+			assert(part->fieldno < format->field_count);
+			struct tuple_field_format *field =
+				&format->fields[part->fieldno];
+			if (field->type != FIELD_TYPE_ANY &&
+			    field->type != part->type) {
 				diag_set(ClientError, ER_FIELD_TYPE_MISMATCH,
-					 key_def->name, i + TUPLE_INDEX_BASE,
-					 field_type_strs[set_type],
-					 field_type_strs[*fmt_type]);
+					 key_def->name,
+					 part->fieldno + TUPLE_INDEX_BASE,
+					 field_type_strs[part->type],
+					 field_type_strs[field->type]);
 				return -1;
 			}
-			*fmt_type = set_type;
+			field->type = part->type;
+
+			/*
+			 * In the tuple, store only offsets necessary
+			 * to access fields of non-sequential keys.
+			 * First field is always simply accessible,
+			 * so we don't store offset for it.
+			 */
+			if (field->offset_slot == TUPLE_OFFSET_SLOT_MISSING &&
+			    !is_sequential && part->fieldno > 0) {
+				field->offset_slot = --current_slot;
+			}
 		}
 	}
+
+	assert(format->fields[0].offset_slot == TUPLE_OFFSET_SLOT_MISSING);
+	assert((uint32_t) (-current_slot * sizeof(uint32_t)) <= UINT16_MAX);
+	format->field_map_size = -current_slot * sizeof(uint32_t);
 	return 0;
 }
 
@@ -170,31 +195,6 @@ tuple_format_new(struct rlist *key_list, struct tuple_format_vtab *vtab)
 		tuple_format_delete(format);
 		return NULL;
 	}
-	/* Set up offset slots */
-	if (format->field_count == 0) {
-		/* Nothing to store */
-		format->field_map_size = 0;
-		return format;
-	}
-	/**
-	 * First field is always simply accessible,
-	 * so we don't store offset for it
-	 */
-	format->fields[0].offset_slot = INT32_MAX;
-
-	int current_slot = 0;
-	for (uint32_t i = 1; i < format->field_count; i++) {
-		/*
-		 * In the tuple, store only offsets necessary to
-		 * quickly access indexed fields.
-		 */
-		if (format->fields[i].type == FIELD_TYPE_ANY)
-			format->fields[i].offset_slot = INT32_MAX;
-		else
-			format->fields[i].offset_slot = --current_slot;
-	}
-	assert((uint32_t) (-current_slot * sizeof(uint32_t)) <= UINT16_MAX);
-	format->field_map_size = -current_slot * sizeof(uint32_t);
 	return format;
 }
 
@@ -236,7 +236,7 @@ tuple_init_field_map(const struct tuple_format *format, uint32_t *field_map,
 		if (key_mp_type_validate(format->fields[i].type, mp_type,
 					 ER_FIELD_TYPE, i + TUPLE_INDEX_BASE))
 			return -1;
-		if (format->fields[i].offset_slot < 0)
+		if (format->fields[i].offset_slot != TUPLE_OFFSET_SLOT_MISSING)
 			field_map[format->fields[i].offset_slot] =
 				(uint32_t) (pos - tuple);
 		mp_next(&pos);
diff --git a/src/box/tuple_format.h b/src/box/tuple_format.h
index ee2a17842a6d91fce6f06b445f86aa3ce4eb77ed..58e73b44a25f29d8e552fad126c9a1e4cbad8b24 100644
--- a/src/box/tuple_format.h
+++ b/src/box/tuple_format.h
@@ -60,6 +60,12 @@ enum { FORMAT_REF_MAX = INT32_MAX};
  */
 enum { TUPLE_INDEX_BASE = 1 };
 
+/*
+ * A special value to indicate that tuple format doesn't store
+ * an offset for a field_id.
+ */
+enum { TUPLE_OFFSET_SLOT_MISSING = INT32_MAX };
+
 /**
  * @brief Tuple field format
  * Support structure for struct tuple_format.
@@ -226,10 +232,9 @@ tuple_field_raw(const struct tuple_format *format, const char *tuple,
 			return tuple;
 		}
 
-		if (format->fields[field_no].offset_slot != INT32_MAX) {
-			int32_t slot = format->fields[field_no].offset_slot;
-			return tuple + field_map[slot];
-		}
+		int32_t offset_slot = format->fields[field_no].offset_slot;
+		if (offset_slot != TUPLE_OFFSET_SLOT_MISSING)
+			return tuple + field_map[offset_slot];
 	}
 	ERROR_INJECT(ERRINJ_TUPLE_FIELD, return NULL);
 	uint32_t field_count = mp_decode_array(&tuple);
diff --git a/test/box/alter_limits.result b/test/box/alter_limits.result
index 68c3b8b2035d81cdc9a091829305abc4e83eebac..e928fee2497d960413d32c1ad27dec0ab28db89e 100644
--- a/test/box/alter_limits.result
+++ b/test/box/alter_limits.result
@@ -526,8 +526,8 @@ index = s:create_index('t1', { type = 'hash' })
 -- field type contradicts field type of another index
 index = s:create_index('t2', { type = 'hash', parts = { 1, 'string' }})
 ---
-- error: Ambiguous field type in index 't2', key part 1. Requested type is string
-    but the field has previously been defined as unsigned
+- error: Ambiguous field type in index 't2', field 1. Requested type is string but
+    the field has previously been defined as unsigned
 ...
 -- ok
 index = s:create_index('t2', { type = 'hash', parts = { 2, 'string' }})
@@ -800,7 +800,7 @@ s.index.primary:select{}
 -- ambiguous field type
 index = s:create_index('string', { type = 'tree', unique =  false, parts = { 2, 'string'}})
 ---
-- error: Ambiguous field type in index 'string', key part 1. Requested type is string
+- error: Ambiguous field type in index 'string', field 2. Requested type is string
     but the field has previously been defined as unsigned
 ...
 -- create index on a non-existing field
diff --git a/test/box/errinj.result b/test/box/errinj.result
index 655baec98bd80604cedba65c58ddcc217d54a019..01f762841e6ae250d50fe7fba94364becf51cba4 100644
--- a/test/box/errinj.result
+++ b/test/box/errinj.result
@@ -500,6 +500,90 @@ tostring(t[1]) .. tostring(t[2]) ..tostring(t[3]) .. tostring(t[4]) .. tostring(
 s:drop()
 ---
 ...
+--
+-- gh-2046: don't store offsets for sequential multi-parts keys
+--
+s = box.schema.space.create('test')
+---
+...
+_ = s:create_index('seq2', { parts = { 1, 'unsigned', 2, 'unsigned' }})
+---
+...
+_ = s:create_index('seq3', { parts = { 1, 'unsigned', 2, 'unsigned', 3, 'unsigned' }})
+---
+...
+_ = s:create_index('seq5', { parts = { 1, 'unsigned', 2, 'unsigned', 3, 'unsigned', 4, 'scalar', 5, 'number' }})
+---
+...
+_ = s:create_index('rnd1', { parts = { 3, 'unsigned' }})
+---
+...
+errinj.set("ERRINJ_TUPLE_FIELD", true)
+---
+- ok
+...
+tuple = s:insert({1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
+---
+...
+tuple
+---
+- [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+tuple[1] -- not-null, always accessible
+---
+- 1
+...
+tuple[2] -- null, doesn't have offset
+---
+- null
+...
+tuple[3] -- not null, has offset
+---
+- 3
+...
+tuple[4] -- null, doesn't have offset
+---
+- null
+...
+tuple[5] -- null, doesn't have offset
+---
+- null
+...
+s.index.seq2:select({1})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+s.index.seq2:select({1, 2})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+s.index.seq3:select({1})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+s.index.seq3:select({1, 2, 3})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+s.index.seq5:select({1})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+s.index.seq5:select({1, 2, 3, 4, 5})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+s.index.rnd1:select({3})
+---
+- - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+...
+errinj.set("ERRINJ_TUPLE_FIELD", false)
+---
+- ok
+...
+s:drop()
+---
+...
 space = box.schema.space.create('test')
 ---
 ...
diff --git a/test/box/errinj.test.lua b/test/box/errinj.test.lua
index 3e71875d45f971ed1137ae2018059ed9abee9fb4..aafe5f822994b4a325fc16afb2affde3fc623f11 100644
--- a/test/box/errinj.test.lua
+++ b/test/box/errinj.test.lua
@@ -154,6 +154,33 @@ tostring(t[1]) .. tostring(t[2]) ..tostring(t[3]) .. tostring(t[4]) .. tostring(
 -- Cleanup
 s:drop()
 
+--
+-- gh-2046: don't store offsets for sequential multi-parts keys
+--
+s = box.schema.space.create('test')
+_ = s:create_index('seq2', { parts = { 1, 'unsigned', 2, 'unsigned' }})
+_ = s:create_index('seq3', { parts = { 1, 'unsigned', 2, 'unsigned', 3, 'unsigned' }})
+_ = s:create_index('seq5', { parts = { 1, 'unsigned', 2, 'unsigned', 3, 'unsigned', 4, 'scalar', 5, 'number' }})
+_ = s:create_index('rnd1', { parts = { 3, 'unsigned' }})
+
+errinj.set("ERRINJ_TUPLE_FIELD", true)
+tuple = s:insert({1, 2, 3, 4, 5, 6, 7, 8, 9, 10})
+tuple
+tuple[1] -- not-null, always accessible
+tuple[2] -- null, doesn't have offset
+tuple[3] -- not null, has offset
+tuple[4] -- null, doesn't have offset
+tuple[5] -- null, doesn't have offset
+s.index.seq2:select({1})
+s.index.seq2:select({1, 2})
+s.index.seq3:select({1})
+s.index.seq3:select({1, 2, 3})
+s.index.seq5:select({1})
+s.index.seq5:select({1, 2, 3, 4, 5})
+s.index.rnd1:select({3})
+errinj.set("ERRINJ_TUPLE_FIELD", false)
+s:drop()
+
 space = box.schema.space.create('test')
 _ = space:create_index('pk')
 errinj.set("ERRINJ_WAL_WRITE", true)