diff --git a/changelogs/unreleased/fully-temporary-spaces.md b/changelogs/unreleased/fully-temporary-spaces.md
new file mode 100644
index 0000000000000000000000000000000000000000..c92aa212d90026b68cdc5a6d67862d65abbc7702
--- /dev/null
+++ b/changelogs/unreleased/fully-temporary-spaces.md
@@ -0,0 +1,6 @@
+## feature/space
+
+* Introduces the fully temporary space type. It is the same as data-temporary
+  but also has temporary metadata. Temporary spaces can now be created in
+  read_only mode, they disappear after server restart and don't exist on
+  replicas (gh-8323).
diff --git a/src/box/alter.cc b/src/box/alter.cc
index a4174012c0e776e322c1a09780d8182ed8134884..8f040756a525847067cfa08a1c2a3d61bbc25d38 100644
--- a/src/box/alter.cc
+++ b/src/box/alter.cc
@@ -566,6 +566,11 @@ space_def_new_from_tuple(struct tuple *tuple, uint32_t errcode,
 			 "local space can't be synchronous");
 		return NULL;
 	}
+	if (space_opts_is_temporary(&opts) && opts.constraint_count > 0) {
+		diag_set(ClientError, ER_UNSUPPORTED, "temporary space",
+			 "constraints");
+		return NULL;
+	}
 	struct space_def *def =
 		space_def_new(id, uid, exact_field_count, name, name_len,
 			      engine_name, engine_name_len, &opts, fields,
@@ -1898,6 +1903,11 @@ update_view_references(struct Select *select, int update_value)
 			diag_set(ClientError, ER_NO_SUCH_SPACE, space_name);
 			goto error;
 		}
+		if (space_is_temporary(space)) {
+			diag_set(ClientError, ER_UNSUPPORTED,
+				 "CREATE VIEW", "temporary spaces");
+			goto error;
+		}
 	}
 	/* Secondly do the job. */
 	for (int i = 0; i < from_tables_count; ++i) {
@@ -2078,6 +2088,27 @@ space_check_alter(struct space *old_space, struct space_def *new_space_def)
 	return 0;
 }
 
+/*
+ * box_process1() bypasses the read-only check for the _space system space
+ * because there it's not yet known if the related space is temporary. Perform
+ * the check here if the space isn't temporary and the statement was issued by
+ * this replica.
+ */
+static int
+filter_temporary_ddl_stmt(struct txn *txn, const struct space_def *def)
+{
+	if (def == NULL)
+		return 0;
+	struct txn_stmt *stmt = txn_current_stmt(txn);
+	if (space_opts_is_temporary(&def->opts)) {
+		txn_stmt_mark_as_temporary(txn, stmt);
+		return 0;
+	}
+	if (stmt->row->replica_id == 0 && recovery_state != INITIAL_RECOVERY)
+		return box_check_writable();
+	return 0;
+}
+
 /**
  * A trigger which is invoked on replace in a data dictionary
  * space _space.
@@ -2161,6 +2192,9 @@ on_replace_dd_space(struct trigger * /* trigger */, void *event)
 			ER_CREATE_SPACE : ER_ALTER_SPACE;
 		def = space_def_new_from_tuple(new_tuple, errcode, region);
 	}
+	if (filter_temporary_ddl_stmt(txn, old_space != NULL ?
+				      old_space->def : def) != 0)
+		return -1;
 	if (new_tuple != NULL && old_space == NULL) { /* INSERT */
 		if (def == NULL)
 			return -1;
@@ -2354,6 +2388,13 @@ on_replace_dd_space(struct trigger * /* trigger */, void *event)
 				  "replication group is immutable");
 			return -1;
 		}
+		if (space_is_temporary(old_space) !=
+		     space_opts_is_temporary(&def->opts)) {
+			diag_set(ClientError, ER_ALTER_SPACE,
+				 old_space->def->name,
+				 "temporariness cannot change");
+			return -1;
+		}
 		if (def->opts.is_view != old_space->def->opts.is_view) {
 			diag_set(ClientError, ER_ALTER_SPACE,
 				  space_name(old_space),
@@ -2483,6 +2524,8 @@ on_replace_dd_index(struct trigger * /* trigger */, void *event)
 	struct space *old_space = space_cache_find(id);
 	if (old_space == NULL)
 		return -1;
+	if (filter_temporary_ddl_stmt(txn, old_space->def) != 0)
+		return -1;
 	if (old_space->def->opts.is_view) {
 		diag_set(ClientError, ER_ALTER_SPACE, space_name(old_space),
 			  "can not add index on a view");
@@ -2742,21 +2785,28 @@ on_replace_dd_truncate(struct trigger * /* trigger */, void *event)
 	struct txn_stmt *stmt = txn_current_stmt(txn);
 	struct tuple *new_tuple = stmt->new_tuple;
 
-	if (new_tuple == NULL) {
-		/* Space drop - nothing to do. */
-		return 0;
-	}
 	if (recovery_state == INITIAL_RECOVERY) {
 		/* Space creation during initial recovery - nothing to do. */
 		return 0;
 	}
 
+	struct tuple *any_tuple = new_tuple;
+	if (any_tuple == NULL)
+		any_tuple = stmt->old_tuple;
 	uint32_t space_id;
-	if (tuple_field_u32(new_tuple, BOX_TRUNCATE_FIELD_SPACE_ID, &space_id) != 0)
+	if (tuple_field_u32(any_tuple, BOX_TRUNCATE_FIELD_SPACE_ID,
+			    &space_id) != 0)
 		return -1;
 	struct space *old_space = space_cache_find(space_id);
 	if (old_space == NULL)
 		return -1;
+	if (space_is_temporary(old_space))
+		txn_stmt_mark_as_temporary(txn, stmt);
+
+	if (new_tuple == NULL) {
+		/* Space drop - nothing else to do. */
+		return 0;
+	}
 
 	/*
 	 * box_process1() bypasses the read-only check for the _truncate system
@@ -2802,9 +2852,11 @@ on_replace_dd_truncate(struct trigger * /* trigger */, void *event)
 	/*
 	 * Modify the WAL header to prohibit
 	 * replication of local & data-temporary
-	 * spaces truncation.
+	 * spaces truncation
+	 * unless it's a temporary space
+	 * in which case the header doesn't exist.
 	 */
-	if (is_temp) {
+	if (is_temp && !space_is_temporary(old_space)) {
 		stmt->row->group_id = GROUP_LOCAL;
 		/*
 		 * The trigger is invoked after txn->n_local_rows
@@ -3829,6 +3881,11 @@ priv_def_check(struct priv_def *priv, enum priv_type priv_type)
 				  grantor->def->name);
 			return -1;
 		}
+		if (space_is_temporary(space)) {
+			diag_set(ClientError, ER_UNSUPPORTED,
+				 "temporary space", "privileges");
+			return -1;
+		}
 		break;
 	}
 	case SC_FUNCTION:
@@ -5070,6 +5127,11 @@ on_replace_dd_space_sequence(struct trigger * /* trigger */, void *event)
 	struct space *space = space_cache_find(space_id);
 	if (space == NULL)
 		return -1;
+	if (space_is_temporary(space)) {
+		diag_set(ClientError, ER_SQL_EXECUTE,
+			 "sequences are not supported for temporary spaces");
+		return -1;
+	}
 	struct sequence *seq = sequence_by_id(sequence_id);
 	if (seq == NULL) {
 		diag_set(ClientError, ER_NO_SUCH_SEQUENCE, int2str(sequence_id));
@@ -5311,6 +5373,13 @@ on_replace_dd_trigger(struct trigger * /* trigger */, void *event)
 				  "resolved on AST building from SQL");
 			return -1;
 		}
+		struct space *space = space_cache_find(space_id);
+		if (space != NULL && space_is_temporary(space)) {
+			diag_set(ClientError, ER_SQL_EXECUTE,
+				 "triggers are not supported for "
+				 "temporary spaces");
+			return -1;
+		}
 
 		struct sql_trigger *old_trigger;
 		if (sql_trigger_replace(trigger_name,
@@ -5365,6 +5434,11 @@ on_replace_dd_func_index(struct trigger *trigger, void *event)
 		space = space_cache_find(space_id);
 		if (space == NULL)
 			return -1;
+		if (space_is_temporary(space)) {
+			diag_set(ClientError, ER_UNSUPPORTED,
+				 "temporary space", "functional indexes");
+			return -1;
+		}
 		index = index_find(space, index_id);
 		if (index == NULL)
 			return -1;
diff --git a/src/box/box.cc b/src/box/box.cc
index 6108174ec5e5f141929a843351f5b0350c8db8fd..e80227000b582585f2d170f8d43be081316c5eee 100644
--- a/src/box/box.cc
+++ b/src/box/box.cc
@@ -3572,11 +3572,16 @@ box_process1(struct request *request, box_tuple_t **result)
 		return -1;
 	/*
 	 * Allow to write to data-temporary and local spaces in the read-only
-	 * mode. To handle space truncation, we postpone the read-only check for
-	 * the _truncate system space till the on_replace trigger is called,
-	 * when we know which space is truncated.
+	 * mode. To handle space truncation and/or ddl operations on temporary
+	 * spaces, we postpone the read-only check for the _truncate, _space &
+	 * _index system spaces till the on_replace trigger is called, when
+	 * we know which spaces are concerned.
 	 */
-	if (space_id(space) != BOX_TRUNCATE_ID &&
+	uint32_t id = space_id(space);
+	if (is_ro_summary &&
+	    id != BOX_TRUNCATE_ID &&
+	    id != BOX_SPACE_ID &&
+	    id != BOX_INDEX_ID &&
 	    !space_is_data_temporary(space) &&
 	    !space_is_local(space) &&
 	    box_check_writable() != 0)
@@ -5729,37 +5734,50 @@ box_read_ffi_enable(void)
 }
 
 int
-box_generate_space_id(uint32_t *new_space_id)
+box_generate_space_id(uint32_t *new_space_id, bool is_temporary)
 {
 	assert(new_space_id != NULL);
-	assert(mp_sizeof_array(0) == 1);
-	char empty_key[1];
-	char *empty_key_end = mp_encode_array(empty_key, 0);
-	struct tuple *res = NULL;
+	uint32_t id_range_begin = !is_temporary ?
+		BOX_SYSTEM_ID_MAX + 1 : BOX_SPACE_ID_TEMPORARY_MIN;
+	uint32_t id_range_end = !is_temporary ?
+		(uint32_t)BOX_SPACE_ID_TEMPORARY_MIN :
+		(uint32_t)BOX_SPACE_MAX + 1;
+	char key_buf[16];
+	char *key_end = key_buf;
+	key_end = mp_encode_array(key_end, 1);
+	key_end = mp_encode_uint(key_end, id_range_end);
 	struct credentials *orig_credentials = effective_user();
 	fiber_set_user(fiber(), &admin_credentials);
-	int rc = box_index_max(BOX_SPACE_ID, 0, empty_key, empty_key_end,
-			       &res);
-	fiber_set_user(fiber(), orig_credentials);
+	auto guard = make_scoped_guard([=] {
+		fiber_set_user(fiber(), orig_credentials);
+	});
+	box_iterator_t *it = box_index_iterator(BOX_SPACE_ID, 0, ITER_LT,
+						key_buf, key_end);
+	if (it == NULL)
+		return -1;
+	struct tuple *res = NULL;
+	int rc = box_iterator_next(it, &res);
+	box_iterator_free(it);
 	if (rc != 0)
 		return -1;
+	assert(res != NULL);
 	uint32_t max_id = 0;
-	if (res != NULL && tuple_field_u32(res, 0, &max_id) != 0)
-		return -1;
-	if (max_id > BOX_SPACE_MAX || max_id < BOX_SYSTEM_ID_MAX)
-		max_id = BOX_SYSTEM_ID_MAX;
+	rc = tuple_field_u32(res, 0, &max_id);
+	assert(rc == 0);
+	if (max_id < id_range_begin)
+		max_id = id_range_begin - 1;
 	*new_space_id = space_cache_find_next_unused_id(max_id);
 	/* Try again if overflowed. */
-	if (*new_space_id > BOX_SPACE_MAX) {
+	if (*new_space_id >= id_range_end) {
 		*new_space_id =
-			space_cache_find_next_unused_id(BOX_SYSTEM_ID_MAX);
+			space_cache_find_next_unused_id(id_range_begin - 1);
 		/*
 		 * The second overflow means all ids are occupied.
 		 * This situation cannot happen in real world with limited
 		 * memory, and its pretty hard to test it, so let's just panic
 		 * if we've run out of ids.
 		 */
-		if (*new_space_id > BOX_SPACE_MAX)
+		if (*new_space_id >= id_range_end)
 			panic("Space id limit is reached");
 	}
 	return 0;
diff --git a/src/box/box.h b/src/box/box.h
index cc1a8f30a7e2c06f047245c62db61d22263908f9..1ac2093dab982045190c07e10c2e4b9648fffe10 100644
--- a/src/box/box.h
+++ b/src/box/box.h
@@ -767,7 +767,7 @@ boxk(int type, uint32_t space_id, const char *format, ...);
 
 /** Generate unique id for non-system space. */
 int
-box_generate_space_id(uint32_t *new_space_id);
+box_generate_space_id(uint32_t *new_space_id, bool is_temporary);
 
 /**
  * Broadcast the identification of the instance
diff --git a/src/box/lua/misc.cc b/src/box/lua/misc.cc
index adf357a51c9c5e1242e78f5998c96d51dba9c01b..8197803d4e19f92e832b6e76fcf2a45f0d7e3b5b 100644
--- a/src/box/lua/misc.cc
+++ b/src/box/lua/misc.cc
@@ -238,8 +238,11 @@ port_msgpack_dump_lua(struct port *base, struct lua_State *L, bool is_flat)
 static int
 lbox_generate_space_id(lua_State *L)
 {
+	assert(lua_gettop(L) >= 1);
+	assert(lua_isboolean(L, 1) == 1);
+	bool is_temporary = lua_toboolean(L, 1) != 0;
 	uint32_t ret = 0;
-	if (box_generate_space_id(&ret) != 0)
+	if (box_generate_space_id(&ret, is_temporary) != 0)
 		return luaT_error(L);
 	lua_pushnumber(L, ret);
 	return 1;
diff --git a/src/box/lua/schema.lua b/src/box/lua/schema.lua
index 1844a905bb7e0e7d3e81ef3ecbe4f3721fbe9b55..87f931ca43536a6d8441a6910befd0924faf7796 100644
--- a/src/box/lua/schema.lua
+++ b/src/box/lua/schema.lua
@@ -700,6 +700,7 @@ box.internal.space.denormalize_format = denormalize_format
 local space_types = {
     'normal',
     'data-temporary',
+    'temporary',
 }
 local function check_space_type(space_type)
     if space_type == nil then
@@ -760,7 +761,7 @@ box.schema.space.create = function(name, options)
     end
     local id = options.id
     if not id then
-        id = internal.generate_space_id()
+        id = internal.generate_space_id(options.type == 'temporary')
     end
     local uid = session.euid()
     if options.user then
@@ -830,10 +831,15 @@ box.schema.space.drop = function(space_id, space_name, opts)
     local _truncate = box.space[box.schema.TRUNCATE_ID]
     local _space_sequence = box.space[box.schema.SPACE_SEQUENCE_ID]
     local _func_index = box.space[box.schema.FUNC_INDEX_ID]
-    local sequence_tuple = _space_sequence:delete{space_id}
-    if sequence_tuple ~= nil and sequence_tuple.is_generated == true then
-        -- Delete automatically generated sequence.
-        box.schema.sequence.drop(sequence_tuple.sequence_id)
+    -- This is needed to support dropping temporary spaces
+    -- in read-only mode, because sequences aren't supported for them yet
+    -- and therefore such requests aren't allowed in read-only mode.
+    if _space_sequence:get(space_id) ~= nil then
+        local sequence_tuple = _space_sequence:delete{space_id}
+        if sequence_tuple.is_generated == true then
+            -- Delete automatically generated sequence.
+            box.schema.sequence.drop(sequence_tuple.sequence_id)
+        end
     end
     for _, t in _trigger.index.space_id:pairs({space_id}) do
         _trigger:delete({t.name})
@@ -847,7 +853,15 @@ box.schema.space.drop = function(space_id, space_name, opts)
         _index:delete{v.id, v.iid}
     end
     revoke_object_privs('space', space_id)
-    _truncate:delete{space_id}
+    -- Deleting from _truncate currently adds a delete entry into WAL even
+    -- if the corresponding space was never truncated. This is a problem for
+    -- temporary spaces, because in such situations it's impossible to
+    -- filter out such entries from the within on_replace trigger which
+    -- basically results in temporary space's metadata getting into WAL
+    -- which breaks some invariants.
+    if _truncate:get{space_id} ~= nil then
+        _truncate:delete{space_id}
+    end
     if _space:delete{space_id} == nil then
         if space_name == nil then
             space_name = '#'..tostring(space_id)
@@ -1592,10 +1606,15 @@ box.schema.index.drop = function(space_id, index_id)
     check_param(index_id, 'index_id', 'number')
     if index_id == 0 then
         local _space_sequence = box.space[box.schema.SPACE_SEQUENCE_ID]
-        local sequence_tuple = _space_sequence:delete{space_id}
-        if sequence_tuple ~= nil and sequence_tuple.is_generated == true then
-            -- Delete automatically generated sequence.
-            box.schema.sequence.drop(sequence_tuple.sequence_id)
+        -- This is needed to support dropping temporary spaces
+        -- in read-only mode, because sequences aren't supported for them yet
+        -- and therefore such requests aren't allowed in read-only mode.
+        if _space_sequence:get(space_id) ~= nil then
+            local sequence_tuple = _space_sequence:delete{space_id}
+            if sequence_tuple.is_generated == true then
+                -- Delete automatically generated sequence.
+                box.schema.sequence.drop(sequence_tuple.sequence_id)
+            end
         end
     end
     local _index = box.space[box.schema.INDEX_ID]
diff --git a/src/box/lua/space.cc b/src/box/lua/space.cc
index 15bd2e2878109518ed975ded832061ed038cf293..726615af4428bd843250055d69c2ccb83d1634c1 100644
--- a/src/box/lua/space.cc
+++ b/src/box/lua/space.cc
@@ -897,6 +897,8 @@ box_lua_space_init(struct lua_State *L)
 	lua_setfield(L, -2, "REPLICA_MAX");
 	lua_pushnumber(L, SQL_BIND_PARAMETER_MAX);
 	lua_setfield(L, -2, "SQL_BIND_PARAMETER_MAX");
+	lua_pushnumber(L, BOX_SPACE_ID_TEMPORARY_MIN);
+	lua_setfield(L, -2, "SPACE_ID_TEMPORARY_MIN");
 	lua_pop(L, 2); /* box, schema */
 
 	static const struct luaL_Reg space_internal_lib[] = {
diff --git a/src/box/memtx_engine.cc b/src/box/memtx_engine.cc
index c2c9475ee6d6ad37873de4ef4302ebfc050b8b01..1a29b18f7d3817bde71ab22cfa870d7ccfb9334b 100644
--- a/src/box/memtx_engine.cc
+++ b/src/box/memtx_engine.cc
@@ -58,6 +58,7 @@
 #include "memtx_space.h"
 #include "memtx_space_upgrade.h"
 #include "tt_sort.h"
+#include "assoc.h"
 
 #include <type_traits>
 
@@ -797,6 +798,46 @@ primary_index_filter(struct space *space, struct index *index, void *arg)
 	return index->def->iid == 0;
 }
 
+/*
+ * Return true if tuple @a data represents temporary space's metadata.
+ * @a space_id is used to determine the tuple's format.
+ */
+static bool
+is_tuple_temporary(const char *data, uint32_t space_id,
+		   struct mh_i32_t *temp_space_ids)
+{
+	static_assert(BOX_SPACE_ID < BOX_INDEX_ID &&
+		      BOX_SPACE_ID < BOX_TRUNCATE_ID,
+		      "in this function temporary space ids are collected "
+		      "while processing tuples of _space and then this info is "
+		      "used when going over _index & _truncate");
+	switch (space_id) {
+	case BOX_SPACE_ID: {
+		uint32_t space_id = BOX_ID_NIL;
+		bool t = space_def_tuple_is_temporary(data, &space_id);
+		if (t)
+			mh_i32_put(temp_space_ids, &space_id,
+				   NULL, NULL);
+		return t;
+	}
+	case BOX_INDEX_ID:
+	case BOX_TRUNCATE_ID: {
+		static_assert(BOX_INDEX_FIELD_SPACE_ID == 0 &&
+			      BOX_TRUNCATE_FIELD_SPACE_ID == 0,
+			      "the following code assumes this is true");
+		uint32_t field_count = mp_decode_array(&data);
+		if (field_count < 1 || mp_typeof(*data) != MP_UINT)
+			return false;
+		uint32_t space_id = mp_decode_uint(&data);
+		mh_int_t pos = mh_i32_find(temp_space_ids, space_id,
+					   NULL);
+		return pos != mh_end(temp_space_ids);
+	}
+	default:
+		return false;
+	}
+}
+
 static struct checkpoint *
 checkpoint_new(const char *snap_dirname, uint64_t snap_io_rate_limit)
 {
@@ -969,6 +1010,8 @@ checkpoint_f(va_list ap)
 	if (xdir_create_xlog(&ckpt->dir, &snap, &ckpt->vclock) != 0)
 		return -1;
 
+	struct mh_i32_t *temp_space_ids = mh_i32_new();
+
 	say_info("saving snapshot `%s'", snap.filename);
 	ERROR_INJECT_SLEEP(ERRINJ_SNAP_WRITE_DELAY);
 	ERROR_INJECT(ERRINJ_SNAP_SKIP_ALL_ROWS, goto done);
@@ -996,6 +1039,10 @@ checkpoint_f(va_list ap)
 			rc = index_read_view_iterator_next_raw(&it, &result);
 			if (rc != 0 || result.data == NULL)
 				break;
+			if (is_tuple_temporary(result.data,
+					       space_rv->id,
+					       temp_space_ids))
+				continue;
 			rc = checkpoint_write_tuple(&snap, space_rv->id,
 						    space_rv->group_id,
 						    result.data, result.size);
@@ -1006,6 +1053,7 @@ checkpoint_f(va_list ap)
 		if (rc != 0)
 			break;
 	}
+	mh_i32_delete(temp_space_ids);
 	if (rc != 0)
 		goto fail;
 	ERROR_INJECT(ERRINJ_SNAP_WRITE_CORRUPTED_INSERT_ROW, {
@@ -1235,6 +1283,7 @@ memtx_join_f(va_list ap)
 {
 	int rc = 0;
 	struct memtx_join_ctx *ctx = va_arg(ap, struct memtx_join_ctx *);
+	struct mh_i32_t *temp_space_ids = mh_i32_new();
 	struct space_read_view *space_rv;
 	read_view_foreach_space(space_rv, &ctx->rv) {
 		FiberGCChecker gc_check;
@@ -1253,6 +1302,10 @@ memtx_join_f(va_list ap)
 			rc = index_read_view_iterator_next_raw(&it, &result);
 			if (rc != 0 || result.data == NULL)
 				break;
+			if (is_tuple_temporary(result.data,
+					       space_rv->id,
+					       temp_space_ids))
+				continue;
 			rc = memtx_join_send_tuple(ctx->stream, space_rv->id,
 						   result.data, result.size);
 			if (rc != 0)
@@ -1262,6 +1315,7 @@ memtx_join_f(va_list ap)
 		if (rc != 0)
 			break;
 	}
+	mh_i32_delete(temp_space_ids);
 	return rc;
 }
 
diff --git a/src/box/schema_def.h b/src/box/schema_def.h
index bb35c911e383ed131cfee2297ed3401e8d9997de..1029ccb1a2bb25ee6e2752a7215e70f5877e163c 100644
--- a/src/box/schema_def.h
+++ b/src/box/schema_def.h
@@ -57,7 +57,14 @@ enum {
 	/** Yet another arbitrary limit which simply needs to
 	 * exist.
 	 */
-	BOX_INDEX_PART_MAX = UINT8_MAX
+	BOX_INDEX_PART_MAX = UINT8_MAX,
+	/**
+	 * Start of the range of default temporary space ids.
+	 * By default they get ids from a special range to avoid conflicts with
+	 * spaces which could arrive via replication. But the user is free to
+	 * choose an id from outside this range.
+	 */
+	BOX_SPACE_ID_TEMPORARY_MIN = (1 << 30),
 };
 static_assert(BOX_INVALID_NAME_MAX <= BOX_NAME_MAX,
 	      "invalid name max is less than name max");
diff --git a/src/box/space.h b/src/box/space.h
index df0b4870f5aca93b8e73b73a3ce08cf07e31d041..d3c7b877f8fb9c96deb51578fcfae40e01e21788 100644
--- a/src/box/space.h
+++ b/src/box/space.h
@@ -353,6 +353,13 @@ space_is_data_temporary(const struct space *space)
 	return space_opts_is_data_temporary(&space->def->opts);
 }
 
+/** Return true if space is temporary. */
+static inline bool
+space_is_temporary(const struct space *space)
+{
+	return space_opts_is_temporary(&space->def->opts);
+}
+
 /** Return true if space is synchronous. */
 static inline bool
 space_is_sync(const struct space *space)
diff --git a/src/box/space_def.c b/src/box/space_def.c
index d7f215aee607f7fb2680776724aaa0d3301ff4b1..3dd9da133bdb0aa57083f21486a2f30d042a1c1b 100644
--- a/src/box/space_def.c
+++ b/src/box/space_def.c
@@ -305,6 +305,7 @@ space_opts_parse_temporary(const char **data, void *vopts,
 const char *space_type_strs[] = {
 	/* [SPACE_TYPE_NORMAL]         = */ "normal",
 	/* [SPACE_TYPE_DATA_TEMPORARY] = */ "data-temporary",
+	/* [SPACE_TYPE_TEMPORARY]      = */ "temporary",
 };
 
 static int
@@ -332,3 +333,42 @@ space_opts_parse_type(const char **data, void *vopts, struct region *region)
 	opts->type = space_type;
 	return 0;
 }
+
+bool
+space_def_tuple_is_temporary(const char *data, uint32_t *space_id)
+{
+	uint32_t field_count = mp_decode_array(&data);
+	if (field_count < BOX_SPACE_FIELD_OPTS + 1)
+		return false;
+
+	static_assert(BOX_SPACE_FIELD_ID == 0,
+		      "the following code relies on this assumption");
+	if (mp_typeof(*data) != MP_UINT)
+		return false;
+	assert(space_id != NULL);
+	*space_id = mp_decode_uint(&data);
+
+	for (uint32_t i = 1; i < BOX_SPACE_FIELD_OPTS; i++)
+		mp_next(&data);
+	if (mp_typeof(*data) != MP_MAP)
+		return false;
+	uint32_t map_size = mp_decode_map(&data);
+
+	for (uint32_t i = 0; i < map_size; i++) {
+		if (mp_typeof(*data) != MP_STR)
+			return false;
+		uint32_t len;
+		const char *str = mp_decode_str(&data, &len);
+		if (len != strlen("type") || memcmp("type", str, len) != 0) {
+			mp_next(&data);
+			continue;
+		}
+		if (mp_typeof(*data) != MP_STR)
+			return false;
+		str = mp_decode_str(&data, &len);
+		enum space_type space_type = strnindex(space_type_strs, str,
+						       len, SPACE_TYPE_DEFAULT);
+		return space_type == SPACE_TYPE_TEMPORARY;
+	}
+	return false;
+}
diff --git a/src/box/space_def.h b/src/box/space_def.h
index 31f1ebe0f02b2dee437630f84c9cffa80699d816..cf1d63a48d0b24a4aad4bd7f99fee91b244dc702 100644
--- a/src/box/space_def.h
+++ b/src/box/space_def.h
@@ -56,6 +56,7 @@ enum space_type {
 	SPACE_TYPE_DEFAULT = -1,
 	SPACE_TYPE_NORMAL = 0,
 	SPACE_TYPE_DATA_TEMPORARY = 1,
+	SPACE_TYPE_TEMPORARY = 2,
 	space_type_MAX,
 };
 
@@ -80,6 +81,12 @@ struct space_opts {
 	 * - changes are not part of a snapshot
 	 * - in SQL: space_def memory is allocated on region and
 	 *   does not require manual release.
+	 *
+	 * If set to SPACE_TYPE_TEMPORARY:
+	 * - all of the above, but
+	 * - metadata is not persisted (doesn't exist at server start)
+	 * - metadata is not replicated (doesn't exist on replicas)
+	 * - this value cannot be changed from or to even for an empty space
 	 */
 	enum space_type type;
 	/**
@@ -138,6 +145,16 @@ space_opts_is_data_temporary(const struct space_opts *opts)
 	return opts->type != SPACE_TYPE_NORMAL;
 }
 
+/**
+ * Check if the space is temporary.
+ */
+static inline bool
+space_opts_is_temporary(const struct space_opts *opts)
+{
+	assert(opts->type != SPACE_TYPE_DEFAULT);
+	return opts->type == SPACE_TYPE_TEMPORARY;
+}
+
 /** Space metadata. */
 struct space_def {
 	/** Space id. */
@@ -241,6 +258,15 @@ space_tuple_format_new(struct tuple_format_vtab *vtab, void *engine,
 		       struct key_def *const *keys, uint16_t key_count,
 		       const struct space_def *def);
 
+/**
+ * Check if msgpack array pointed to by @a data represents a space definition
+ * tuple which corresponds to a temporary space.
+ * If @a space_id is not NULL the id of the space will be written into it in
+ * case of success.
+ */
+bool
+space_def_tuple_is_temporary(const char *data, uint32_t *space_id);
+
 #if defined(__cplusplus)
 } /* extern "C" */
 
diff --git a/src/box/sql/select.c b/src/box/sql/select.c
index a056b5de75893fefe308b6e20995f482083402e5..71c53b107ca9d095591a08145858893359e336b0 100644
--- a/src/box/sql/select.c
+++ b/src/box/sql/select.c
@@ -4850,7 +4850,7 @@ selectExpander(Walker * pWalker, Select * p)
 	ExprList *pEList;
 	struct SrcList_item *pFrom;
 	Expr *pE, *pRight, *pExpr;
-	u16 selFlags = p->selFlags;
+	u32 selFlags = p->selFlags;
 
 	p->selFlags |= SF_Expanded;
 	if (NEVER(p->pSrc == 0) || (selFlags & SF_Expanded) != 0) {
diff --git a/src/box/sql/vdbe.c b/src/box/sql/vdbe.c
index 19533468652ba80c1ae403f59e43da8a2f98b273..fdd0c7f99399368e45a05f1b4d721fc7ef1ed469 100644
--- a/src/box/sql/vdbe.c
+++ b/src/box/sql/vdbe.c
@@ -4324,7 +4324,7 @@ case OP_GenSpaceid: {
 	assert(pOp->p1 > 0);
 	pOut = vdbe_prepare_null_out(p, pOp->p1);
 	uint32_t u;
-	if (box_generate_space_id(&u) != 0)
+	if (box_generate_space_id(&u, false) != 0)
 		goto abort_due_to_error;
 	mem_set_uint(pOut, u);
 	break;
diff --git a/src/box/tuple_constraint_fkey.c b/src/box/tuple_constraint_fkey.c
index a1a1209de36301ad02712263391011d1fa2d30c5..9c7c343839ca1ef6e528dd54dc2017dcf8c6329a 100644
--- a/src/box/tuple_constraint_fkey.c
+++ b/src/box/tuple_constraint_fkey.c
@@ -629,6 +629,12 @@ tuple_constraint_fkey_check_spaces(struct tuple_constraint *constr,
 				   struct space *space,
 				   struct space *foreign_space)
 {
+	if (space_is_temporary(foreign_space)) {
+		diag_set(ClientError, ER_CREATE_FOREIGN_KEY,
+			 constr->def.name, constr->space->def->name,
+			 "foreign space can't be temporary");
+		return -1;
+	}
 	if (space_is_data_temporary(foreign_space) &&
 	    !space_is_data_temporary(space)) {
 		diag_set(ClientError, ER_CREATE_FOREIGN_KEY,
diff --git a/src/box/txn.c b/src/box/txn.c
index 0cdae8093332cb407e5d6fb8f0adf7168c05a6b1..0b47675bc5c3215a51f571bdf3aff62c495dc0a9 100644
--- a/src/box/txn.c
+++ b/src/box/txn.c
@@ -850,7 +850,9 @@ txn_journal_entry_new(struct txn *txn)
 			rlist_splice(&txn->on_commit, &stmt->on_commit);
 		}
 
-		/* A read (e.g. select) request */
+		/* A read (e.g. select) request or
+		 * a temporary's space metadata update.
+		 */
 		if (stmt->row == NULL)
 			continue;
 
@@ -1544,3 +1546,12 @@ txn_attach(struct txn *txn)
 	trigger_add(&fiber()->on_yield, &txn->fiber_on_yield);
 	trigger_add(&fiber()->on_stop, &txn->fiber_on_stop);
 }
+
+void
+txn_stmt_mark_as_temporary(struct txn *txn, struct txn_stmt *stmt)
+{
+	assert(stmt->row != NULL);
+	/* Revert row counter increases. */
+	txn_update_row_counts(txn, stmt, -1);
+	stmt->row = NULL;
+}
diff --git a/src/box/txn.h b/src/box/txn.h
index 03ef60a253605b2e5fd25a3491ce48923120f658..0750e5f786d75387ca6214d4fb3c17ed15c27860 100644
--- a/src/box/txn.h
+++ b/src/box/txn.h
@@ -835,6 +835,24 @@ txn_is_fully_local(const struct txn *txn)
 	       txn->n_applier_rows == 0;
 }
 
+/**
+ * Mark @a stmt as temporary by removing the associated stmt->row
+ * and update @a txn accordingly.
+ *
+ * This function is called from on_replace_dd_* triggers to filter
+ * temporary space's metadata updates from WAL.
+ *
+ * NOTE: This could also be implemented by just not creating the stmt->row
+ * when txn_commit_stmt is called, but it would be less efficient that way
+ * as it would require putting an expensive check on a hot path.
+ * Doing the check inside the on_replace trigger is much cheaper
+ * as the data we're interested in (result of space_is_temporary(space))
+ * is readily available at that point.
+ * The downside of confusing control flow is outweighed by the efficiency.
+ */
+void
+txn_stmt_mark_as_temporary(struct txn *txn, struct txn_stmt *stmt);
+
 /**
  * End a statement. In autocommit mode, end
  * the current transaction as well.
diff --git a/test/box-luatest/fully-temporary_spaces_test.lua b/test/box-luatest/fully-temporary_spaces_test.lua
new file mode 100644
index 0000000000000000000000000000000000000000..9e6e108b482dab80cd3c8eecbec0cacac7f22a5e
--- /dev/null
+++ b/test/box-luatest/fully-temporary_spaces_test.lua
@@ -0,0 +1,523 @@
+local fio = require('fio')
+local xlog = require('xlog')
+local server = require('luatest.server')
+local replica_set = require('luatest.replica_set')
+local t = require('luatest')
+local g = t.group()
+local _ = nil
+
+g.before_all(function()
+    g.server = server:new({alias = 'master'})
+end
+)
+g.after_all(function()
+    _ = g.server.process and g.server:stop()
+    _ = g.replica_set and g.replica_set:drop()
+end)
+
+g.before_each(function()
+    if not g.server.process then
+        g.server:start()
+    end
+end)
+
+g.after_each(function()
+    _ = g.server.process and g.server:exec(function()
+        box.cfg { read_only = false }
+        for _, s in pairs({'test', 'datatemp', 'temp'}) do
+            local _ = box.space[s] and box.space[s]:drop()
+        end
+    end)
+end)
+
+-- check basic invariants of temporary spaces
+g.test_temporary_create = function()
+    g.server:exec(function()
+        local _space = box.space._space
+
+        local s = box.schema.space.create('temp', { type = 'temporary' })
+        t.assert_equals(s.temporary, true)
+        t.assert_equals(s.type, 'temporary')
+
+        --
+        -- Check automatic/manual space id.
+        --
+        s = box.schema.space.create('temp2', { type = 'temporary', id = 9999 })
+        t.assert_equals(s.type, 'temporary')
+        s:drop()
+
+        -- If id is not specified, it will be chosen in the special range.
+        s = box.schema.space.create('temp2', { type = 'temporary' })
+        t.assert_equals(s.id, box.space.temp.id + 1)
+
+        s = box.schema.space.create('temp3', { type = 'temporary' })
+        t.assert_equals(s.id, box.space.temp.id + 2)
+
+        -- Now we have an unoccupied space id box.space.temp.id + 1
+        box.space.temp2:drop()
+
+        -- But it's not chosen, because space ids grow, until they can't
+        s = box.schema.space.create('temp4', { type = 'temporary' })
+        t.assert_equals(s.id, box.space.temp.id + 3)
+        s:drop()
+
+        -- Now there's no more room to grow...
+        local BOX_SPACE_MAX = 0x7fffffff
+        box.schema.space.create('temp2', { type = 'temporary',
+                                           id = BOX_SPACE_MAX })
+
+        -- ... therefore we start filling in the gaps
+        s = box.schema.space.create('temp4', { type = 'temporary' })
+        t.assert_equals(s.id, box.space.temp.id + 1)
+        s:drop()
+
+        box.space.temp2:drop()
+        box.space.temp3:drop()
+
+        --
+        -- Here's all the ways you can create a data-temporary space now
+        --
+        s = box.schema.space.create('datatemp', { temporary = true })
+        t.assert_equals(s.temporary, true)
+        t.assert_equals(s.type, 'data-temporary')
+        s:drop()
+
+        s = box.schema.space.create('datatemp', { type = 'data-temporary' })
+        t.assert_equals(s.temporary, true)
+        t.assert_equals(s.type, 'data-temporary')
+        s:drop()
+
+        t.assert_error_msg_contains(
+            "only one of 'type' or 'temporary' may be specified",
+            box.schema.space.create, 'temp',
+            { type = 'data-temporary', temporary = true }
+        )
+
+        --
+        -- Here's all the ways you can create a normal space now
+        --
+        s = box.schema.space.create('normal', { temporary = false })
+        t.assert_equals(s.temporary, false)
+        t.assert_equals(s.type, 'normal')
+        s:drop()
+
+        s = box.schema.space.create('normal', { type = 'normal' })
+        t.assert_equals(s.temporary, false)
+        t.assert_equals(s.type, 'normal')
+        s:drop()
+
+        t.assert_error_msg_contains(
+            "only one of 'type' or 'temporary' may be specified",
+            box.schema.space.create, 'temp',
+            { type = 'normal', temporary = false }
+        )
+
+
+        t.assert_error_msg_contains(
+            "only one of 'type' or 'temporary' may be specified",
+            box.schema.space.create, 'temp',
+            { type = 'temporary', temporary = true }
+        )
+
+        t.assert_error_msg_contains(
+            "unknown space type, must be one of: 'normal', 'data-temporary'," ..
+            " 'temporary'",
+            box.schema.space.create, 'super', { type = 'super-temporary' }
+        )
+
+        t.assert_error_msg_contains(
+            "engine does not support data-temporary spaces",
+            box.schema.space.create,
+                'no-vinyl', { engine = 'vinyl', type = 'temporary' }
+        )
+
+        s = box.schema.space.create('datatemp', { type = 'data-temporary' })
+        t.assert_error_msg_contains(
+            "temporariness cannot change",
+            s.alter, s, { type = 'temporary' }
+        )
+        t.assert_error_msg_contains(
+            "temporariness cannot change",
+            _space.update,
+                _space, s.id, {{'=', 6, { type = 'temporary' }}}
+        )
+
+        s = box.space.temp
+        t.assert_error_msg_contains(
+            "temporariness cannot change",
+            s.alter, s, { type = 'data-temporary' }
+        )
+        t.assert_error_msg_contains(
+            "temporariness cannot change",
+            _space.update,
+                _space, s.id, {{'=', 6, { type = 'data-temporary' }}}
+        )
+    end)
+end
+
+-- check which features aren't supported for temporary spaces
+g.test_temporary_dont_support = function()
+    g.server:exec(function()
+        local s = box.schema.space.create('temp', { type = 'temporary',
+                                                        id = 1111111111 })
+        s:format {{'k', 'unsigned'}}
+        s:create_index('pk')
+        box.schema.func.create('foo', {
+            is_sandboxed = true,
+            is_deterministic = true,
+            body = "function() end"
+        })
+        t.assert_error_msg_equals(
+            "temporary space does not support functional indexes",
+            s.create_index, s, 'func', { func = 'foo' }
+        )
+
+        t.assert_error_msg_equals(
+            "temporary space does not support privileges",
+            box.schema.user.grant, 'guest', 'read', 'space', s.name
+        )
+    end)
+end
+
+-- check which sql features aren't supported for temporary spaces
+g.test_temporary_sql_dont_support = function()
+    g.server:exec(function()
+        local _space = box.space._space
+
+        box.schema.space.create('temp', { type = 'temporary',
+                                              id = 1111111112 })
+        box.schema.space.create('test', { format = { 'k', 'unsigned' } })
+
+        local function sql(stmt)
+            local ok, err = box.execute(stmt)
+            local _ = ok == nil and error(err)
+        end
+
+        t.assert_error_msg_contains(
+            "triggers are not supported for temporary spaces",
+            sql, [[
+                create trigger "my_trigger" before insert on "temp"
+                for each row begin
+                    insert into "temp" values (1);
+                end;
+            ]]
+        )
+
+        t.assert_error_msg_contains(
+            "sequences are not supported for temporary spaces",
+            sql, [[
+                alter table "temp"
+                    add column "k" integer primary key autoincrement
+            ]]
+        )
+
+        -- alter table works though
+        sql([[ alter table "temp" add column "k" int primary key ]])
+
+        t.assert_error_msg_contains(
+            "foreign space can't be temporary",
+            sql, [[
+                create table "my_table" (
+                    "id" integer primary key,
+                    "k" integer,
+                    foreign key ("k") references "temp" ("k")
+                )
+            ]]
+        )
+
+        t.assert_error_msg_contains(
+            "temporary space does not support constraints",
+            sql, [[
+                alter table "temp"
+                add constraint "fk" foreign key ("k") references "test" ("k")
+            ]]
+        )
+
+        -- CREATE VIEW
+
+        local function assert_create_view_not_supported(create_view_sql)
+            t.assert_error_msg_contains(
+                "CREATE VIEW does not support temporary spaces",
+                sql, create_view_sql
+            )
+            t.assert_error_msg_contains(
+                "CREATE VIEW does not support temporary spaces",
+                _space.insert,
+                    _space, {
+                        9999, 1, 'my_view', 'memtx', 1,
+                        { sql = create_view_sql, view = true },
+                        {{ type = 'unsigned', name = 'ID',
+                           nullable_action = 'none', is_nullable = true }},
+                    }
+            )
+        end
+
+        box.space._session_settings:update({'sql_seq_scan'}, {{'=', 2, true}})
+        assert_create_view_not_supported([[
+            create view "my_view" as select * from "temp"
+        ]])
+
+        assert_create_view_not_supported([[
+            create view "my_view" as
+            select * from (select * from (select * from "temp"))
+        ]])
+
+        assert_create_view_not_supported([[
+            create view "my_view" as
+            select * from (select 1 x) where x in (select * from "temp")
+        ]])
+
+        assert_create_view_not_supported([[
+            create view "my_view" as values ((select * from "temp"))
+        ]])
+
+        assert_create_view_not_supported([[
+            create view "my_view" as
+            values (1) union select * from "temp"
+        ]])
+
+        -- CHECK constraints
+
+        t.assert_error_msg_contains(
+            "temporary space does not support constraints",
+            sql, [[
+                alter table "temp" add constraint "c2" check ( "k" > 0 )
+            ]]
+        )
+    end)
+end
+
+-- check that CRUD operations on space meta-data works in read-only mode
+g.test_temporary_read_only = function()
+    g.server:exec(function()
+        local _space, _index = box.space._space, box.space._index
+        box.cfg { read_only = true }
+        t.assert(box.info.ro)
+
+        t.assert_error_msg_contains(
+            "Can't modify data on a read-only instance",
+            box.schema.space.create, 'datatemp', { temporary = true }
+        )
+
+        -- space create works
+        local s = box.schema.space.create('temp', { type = 'temporary',
+                                                        id = 1111111113 })
+
+        -- space rename works
+        s:rename('newname')
+        t.assert_equals(s.name, box.space.newname.name)
+        _space:update(s.id, {{'=', 3, 'temp'}})
+        t.assert_equals(s.name, box.space.temp.name)
+
+        -- format change works
+        t.assert_equals(s:format(), {})
+        s:format {{'k', 'number'}}
+        t.assert_equals(s:format(), {{name = 'k', type = 'number'}})
+        s:alter { format = {{'k', 'number'}, {'v', 'any'}} }
+        t.assert_equals(
+            s:format(),
+            {{name = 'k', type = 'number'}, {name = 'v', type = 'any'}}
+        )
+
+        -- index create works
+        local i = s:create_index('first', { type = 'HASH' })
+        t.assert(s.index.first)
+
+        -- index rename works
+        i:rename('pk')
+        t.assert_equals(i.name, s.index.pk.name)
+        _index:update({s.id, i.id}, {{'=', 3, 'first'}})
+        t.assert_equals(i.name, s.index.first.name)
+
+        -- index alter works
+        t.assert_equals(i.type, 'HASH')
+        i:alter { type = 'TREE' }
+        t.assert_equals(i.type, 'TREE')
+
+        -- on_replace triggers even work
+        local tbl = {}
+        s:on_replace(function(_, new_tuple) table.insert(tbl, new_tuple) end)
+        t.assert_equals(#s:on_replace(), 1)
+        s:auto_increment{'foo'}
+        t.assert_equals(tbl, {{1, 'foo'}})
+        s:on_replace(nil, s:on_replace()[1])
+        t.assert_equals(#s:on_replace(), 0)
+
+        -- truncate works
+        t.assert_equals(s:len(), 1)
+        s:truncate()
+        t.assert_equals(s:len(), 0)
+
+        box.space._session_settings:update({'sql_seq_scan'}, {{'=', 2, true}})
+        -- basic sql works
+        box.execute [[ insert into "temp" values (420, 69), (13, 37) ]]
+        t.assert_equals(
+            box.execute [[ select * from "temp" ]].rows,
+            {{13, 37}, {420, 69}}
+        )
+        box.execute [[ truncate table "temp" ]]
+        local count = box.execute [[ select count(*) from "temp" ]].rows[1]
+        t.assert_equals(count, {0})
+        box.execute [[ drop table "temp" ]]
+        t.assert(not box.space.temp)
+
+        s = box.schema.space.create('temp', { type = 'temporary',
+                                                  id = 1111111114 })
+
+        -- all kinds of indexes work
+        s:create_index('tree', { type = 'TREE' })
+        s:create_index('hash', { type = 'HASH' })
+        s:create_index('rtree', {
+            type = 'RTREE', unique = false, parts = {2, 'array'}
+        })
+        s:create_index('bitset', {
+            type = 'BITSET', unique = false, parts = {3, 'unsigned'}
+        })
+
+        local row = box.tuple.new {1, {2, 3}, 4}
+        s:insert(row)
+
+        t.assert_equals(s.index.hash:get(1), row)
+        t.assert_equals(s.index.tree:get(1), row)
+        t.assert_equals(s.index.rtree:select({2, 3}), {row})
+        t.assert_equals(s.index.bitset:select(4), {row})
+
+        s:truncate()
+
+        -- index drop works
+        s.index.bitset:drop()
+        s.index.rtree:drop()
+        s.index.hash:drop()
+        s.index.tree:drop()
+
+        -- space drop works
+        s:drop()
+    end)
+end
+
+-- check temporary space definitions aren't replicated
+g.test_meta_data_not_replicated = function()
+    g.server:stop()
+    g.replica_set = replica_set:new{}
+    local replication = {
+        server.build_listen_uri('master', g.replica_set.id),
+        server.build_listen_uri('replica_1', g.replica_set.id),
+    }
+    g.replica_set:build_and_add_server{
+        alias = 'master',
+        box_cfg = { read_only = false, replication = replication },
+    }
+    g.replica_set:build_and_add_server{
+        alias = 'replica_1',
+        box_cfg = { read_only = true, replication = replication },
+    }
+    g.replica_set:start()
+
+    g.master = g.replica_set:get_server('master')
+    g.master:exec(function()
+        box.schema.space.create('temp', { type = 'temporary',
+                                              id = 1111111115 })
+        box.schema.space.create('datatemp', { temporary = true })
+    end)
+
+    g.replica_1 = g.replica_set:get_server('replica_1')
+    g.replica_1:wait_for_vclock_of(g.master)
+    -- temporary is not replicated via relay
+    g.replica_1:exec(function()
+        t.assert(box.space.datatemp)
+        t.assert(not box.space.temp)
+    end)
+
+    g.replica_2 = g.replica_set:build_and_add_server{
+        alias = 'replica_2',
+        box_cfg = { replication = replication },
+    }
+    g.replica_2:start{wait_until_ready = true}
+    g.replica_2:wait_for_vclock_of(g.master)
+    -- temporary is not replicated via snapshot
+    g.replica_2:exec(function()
+        t.assert(box.space.datatemp)
+        t.assert(not box.space.temp)
+    end)
+end
+
+g.after_test('test_meta_data_not_replicated', function()
+    g.replica_set:drop()
+end)
+
+local function check_wal_and_snap(expected)
+    local id_min = g.server:eval 'return box.schema.SPACE_ID_TEMPORARY_MIN'
+
+    local function is_meta_local_space_id(id)
+        return type(id) == 'number' and (id >= id_min or id == 9999)
+    end
+
+    local function contains_meta_local_space_id(entry)
+        local key, tuple = entry.BODY.key, entry.BODY.tuple
+        return key and key:pairs():any(is_meta_local_space_id)
+            or tuple and tuple:pairs():any(is_meta_local_space_id)
+    end
+
+    for _, f in pairs(fio.listdir(g.server.workdir)) do
+        if not f:endswith('.xlog') and not f:endswith('.xlog') then
+            goto continue
+        end
+
+        local path = fio.pathjoin(g.server.workdir, f)
+        local x = xlog.pairs(path)
+            :filter(contains_meta_local_space_id)
+            :totable()
+        if #x > 0 then
+            x = x[1]
+            x = {
+                type = x.HEADER.type,
+                space_id = x.BODY.space_id,
+                tuple = x.BODY.tuple,
+                key = x.BODY.key,
+            }
+            t.assert_equals({f, x}, {f, expected})
+        end
+
+        ::continue::
+    end
+end
+
+-- check temporary space definitions aren't persisted
+g.test_meta_data_not_persisted = function()
+    g.server:exec(function()
+        -- temporary space's metadata will go into WAL and snapshot
+        local s = box.schema.space.create('datatemp', { temporary = true })
+        s:create_index('pk')
+        s:put{1,2,3}
+        s:truncate()
+
+        -- temporary space's metadata will not go into WAL or snapshot
+        s = box.schema.space.create('temp', { type = 'temporary',
+                                                  id = 1111111116 })
+        s:create_index('pk')
+        s:put{1,2,3}
+        s:truncate()
+
+        -- there's nothing special about space ids
+        s = box.schema.space.create('test', { type = 'temporary',
+                                              id = 9999 })
+        s:create_index('pk')
+        s:put{1,2,3}
+        s:truncate()
+    end)
+
+    check_wal_and_snap({})
+
+    g.server:restart()
+
+    g.server:exec(function()
+        -- temporary space exists after restart
+        t.assert(box.space.datatemp)
+        -- temporary space doesn't
+        t.assert(not box.space.temp)
+
+        box.snapshot()
+    end)
+
+    check_wal_and_snap({})
+end
diff --git a/test/box/access.result b/test/box/access.result
index a75a7647ccf8806b9a942f6f26bd7e28d533a611..691791813486c4e077fa279cb56204b87d8e3204 100644
--- a/test/box/access.result
+++ b/test/box/access.result
@@ -1519,6 +1519,9 @@ box.schema.user.grant("tester", "read", "space", "_user")
 box.schema.user.grant("tester", "read", "space", "_func")
 ---
 ...
+box.schema.user.grant("tester", "read", "space", "_truncate")
+---
+...
 -- failed create
 box.session.su("tester")
 ---
diff --git a/test/box/access.test.lua b/test/box/access.test.lua
index 1db7fc314de467579b89439b7d30134e6fbb5a8c..f7241ea5826d14d89996bfd99cb92ce0454d0a46 100644
--- a/test/box/access.test.lua
+++ b/test/box/access.test.lua
@@ -588,6 +588,7 @@ box.schema.func.create('test_func')
 box.session.su("admin")
 box.schema.user.grant("tester", "read", "space", "_user")
 box.schema.user.grant("tester", "read", "space", "_func")
+box.schema.user.grant("tester", "read", "space", "_truncate")
 -- failed create
 box.session.su("tester")
 box.schema.space.create("test_space")
diff --git a/test/box/access_misc.result b/test/box/access_misc.result
index c4b64e2bb7f73ee00ea8d05a56f239c52afa4146..be2464104f95db6613121d802b2d4f1972198f1f 100644
--- a/test/box/access_misc.result
+++ b/test/box/access_misc.result
@@ -78,7 +78,7 @@ s:delete(1)
 ...
 s:drop()
 ---
-- error: Write access to space '_space_sequence' is denied for user 'testus'
+- error: Read access to space '_space_sequence' is denied for user 'testus'
 ...
 --
 -- Check double revoke
@@ -126,7 +126,7 @@ s:insert({3})
 ...
 s:drop()
 ---
-- error: Write access to space '_space_sequence' is denied for user 'testus'
+- error: Read access to space '_space_sequence' is denied for user 'testus'
 ...
 session.su('admin')
 ---
@@ -169,7 +169,7 @@ s:delete({3})
 ...
 s:drop()
 ---
-- error: Write access to space '_space_sequence' is denied for user 'guest'
+- error: Read access to space '_space_sequence' is denied for user 'guest'
 ...
 gs = box.schema.space.create('guest_space')
 ---
diff --git a/test/box/alter.result b/test/box/alter.result
index 96a322d0621ff2acfca25742d72cbb719cb442ec..f6222d5372248f57d311efbf1d3b1e703a80ddec 100644
--- a/test/box/alter.result
+++ b/test/box/alter.result
@@ -102,7 +102,7 @@ _space:update({_space.id}, {{'-', 1, 2}})
 --
 -- Create a space
 --
-t = _space:insert{box.internal.generate_space_id(), ADMIN, 'hello', 'memtx', 0, EMPTY_MAP, {}}
+t = _space:insert{box.internal.generate_space_id(false), ADMIN, 'hello', 'memtx', 0, EMPTY_MAP, {}}
 ---
 ...
 -- Check that a space exists
diff --git a/test/box/alter.test.lua b/test/box/alter.test.lua
index 9eb3f19f4033b919f36ae9bf4ca56ecb8626da19..4d473cb6cb33d085badb54f86bd79efa1756eb3c 100644
--- a/test/box/alter.test.lua
+++ b/test/box/alter.test.lua
@@ -45,7 +45,7 @@ _space:update({_space.id}, {{'-', 1, 2}})
 --
 -- Create a space
 --
-t = _space:insert{box.internal.generate_space_id(), ADMIN, 'hello', 'memtx', 0, EMPTY_MAP, {}}
+t = _space:insert{box.internal.generate_space_id(false), ADMIN, 'hello', 'memtx', 0, EMPTY_MAP, {}}
 -- Check that a space exists
 space = box.space[t[1]]
 space.id
diff --git a/test/box/stat.result b/test/box/stat.result
index 9bd9bb22b5dd9e4932358a7b60292f8db0b8a7d9..45b51e952910b67661d165c6ed5ff2ec55dba61a 100644
--- a/test/box/stat.result
+++ b/test/box/stat.result
@@ -33,6 +33,11 @@ box.stat.ERROR.total
 space = box.schema.space.create('tweedledum')
 ---
 ...
+-- create_space performs a select when choosing a new space id
+box.stat.SELECT.total
+---
+- 1
+...
 index = space:create_index('primary', { type = 'hash' })
 ---
 ...
@@ -59,7 +64,7 @@ box.stat.REPLACE.total
 ...
 box.stat.SELECT.total
 ---
-- 4
+- 5
 ...
 -- check exceptions
 space:get('Impossible value')
@@ -77,14 +82,14 @@ space:get(1)
 ...
 box.stat.SELECT.total
 ---
-- 5
+- 6
 ...
 space:get(11)
 ---
 ...
 box.stat.SELECT.total
 ---
-- 6
+- 7
 ...
 space:select(5)
 ---
@@ -92,7 +97,7 @@ space:select(5)
 ...
 box.stat.SELECT.total
 ---
-- 7
+- 8
 ...
 space:select(15)
 ---
@@ -100,14 +105,14 @@ space:select(15)
 ...
 box.stat.SELECT.total
 ---
-- 8
+- 9
 ...
 for _ in space:pairs() do end
 ---
 ...
 box.stat.SELECT.total
 ---
-- 9
+- 10
 ...
 -- reset
 box.stat.reset()
diff --git a/test/box/stat.test.lua b/test/box/stat.test.lua
index 6ee1ec440bbaa4220b0133bf9e20552a5d6ba2fa..ab4b3d134f71b4f89e59b5b323bb404dac63b0bd 100644
--- a/test/box/stat.test.lua
+++ b/test/box/stat.test.lua
@@ -11,6 +11,9 @@ box.stat.SELECT.total
 box.stat.ERROR.total
 
 space = box.schema.space.create('tweedledum')
+-- create_space performs a select when choosing a new space id
+box.stat.SELECT.total
+
 index = space:create_index('primary', { type = 'hash' })
 
 -- check stat_cleanup