diff --git a/src/knot/catalog/interpret.c b/src/knot/catalog/interpret.c
index 7d0ae343356f6913863d4838a51ce34dffe8269f..28e1cfdd3e2fbd3f78c91d9101468ee79cf0e440 100644
--- a/src/knot/catalog/interpret.c
+++ b/src/knot/catalog/interpret.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -18,7 +18,7 @@
 #include <stdio.h>
 
 #include "knot/catalog/interpret.h"
-#include "knot/zone/contents.h"
+#include "knot/journal/serialization.h"
 
 struct cat_upd_ctx;
 typedef int (*cat_interpret_cb_t)(zone_node_t *node, struct cat_upd_ctx *ctx);
@@ -28,6 +28,7 @@ typedef struct cat_upd_ctx {
 	const zone_contents_t *complete_conts;
 	int apex_labels;
 	bool remove;
+	bool zone_diff;
 	catalog_t *check;
 	cat_interpret_cb_t member_cb;
 	cat_interpret_cb_t property_cb;
@@ -80,19 +81,19 @@ static int interpret_node(zone_node_t *node, void * _ctx)
 	}
 }
 
-static int interpret_zone(const zone_contents_t *zone, cat_upd_ctx_t *ctx)
+static int interpret_zone(zone_diff_t *zdiff, cat_upd_ctx_t *ctx)
 {
 	knot_dname_storage_t sub;
 	if (knot_dname_store(sub, (uint8_t *)CATALOG_ZONES_LABEL) == 0 ||
-	    catalog_dname_append(sub, zone->apex->owner) == 0) {
+	    catalog_dname_append(sub, zdiff->apex->owner) == 0) {
 		return KNOT_EINVAL;
 	}
 
-	if (zone_contents_find_node(zone, sub) == NULL) {
+	if (zone_tree_get(&zdiff->nodes, sub) == NULL) {
 		return KNOT_EOK;
 	}
 
-	return zone_tree_sub_apply(zone->nodes, sub, true, interpret_node, ctx);
+	return zone_tree_sub_apply(&zdiff->nodes, sub, true, interpret_node, ctx);
 }
 
 static const knot_dname_t *property_get_member(const zone_node_t *prop_node,
@@ -127,6 +128,11 @@ static int cat_update_add_memb(zone_node_t *node, cat_upd_ctx_t *ctx)
 		return KNOT_ERROR;
 	}
 
+	const knot_rdataset_t *counter_ptr = node_rdataset(binode_counterpart(node), KNOT_RRTYPE_PTR);
+	if (knot_rdataset_subset(ptr, counter_ptr)) {
+		return KNOT_EOK;
+	}
+
 	knot_rdata_t *rdata = ptr->rdata;
 	int ret = KNOT_EOK;
 	for (int i = 0; ret == KNOT_EOK && i < ptr->count; i++) {
@@ -158,6 +164,11 @@ static int cat_update_add_grp(zone_node_t *node, cat_upd_ctx_t *ctx)
 		return KNOT_ERROR;
 	}
 
+	const knot_rdataset_t *counter_txt = node_rdataset(binode_counterpart(node), KNOT_RRTYPE_TXT);
+	if (knot_rdataset_subset(txt, counter_txt)) {
+		return KNOT_EOK;
+	}
+
 	const char *newgr = "";
 	size_t grlen = 0;
 	if (!ctx->remove) {
@@ -175,15 +186,34 @@ static int cat_update_add_grp(zone_node_t *node, cat_upd_ctx_t *ctx)
 }
 
 int catalog_update_from_zone(catalog_update_t *u, struct zone_contents *zone,
+                             const zone_diff_t *zone_diff,
                              const struct zone_contents *complete_contents,
                              bool remove, catalog_t *check, ssize_t *upd_count)
 {
-	cat_upd_ctx_t ctx = { u, complete_contents, knot_dname_labels(zone->apex->owner, NULL),
-	                      remove, check, cat_update_add_memb, cat_update_add_grp };
+	int ret = KNOT_EOK;
+	zone_diff_t zdiff;
+	assert(zone == NULL || zone_diff == NULL);
+	if (zone != NULL) {
+		zone_diff_from_zone(&zdiff, zone);
+	} else {
+		zdiff = *zone_diff;
+	}
+	cat_upd_ctx_t ctx = { u, complete_contents, knot_dname_labels(zdiff.apex->owner, NULL),
+	                      remove, zone_diff != NULL, check, cat_update_add_memb, cat_update_add_grp };
 
 	pthread_mutex_lock(&u->mutex);
 	*upd_count -= trie_weight(u->upd);
-	int ret = interpret_zone(zone, &ctx);
+	if (zone_diff != NULL) {
+		zone_diff_reverse(&zdiff);
+		ctx.remove = true;
+		ret = interpret_zone(&zdiff, &ctx);
+		zone_diff_reverse(&zdiff);
+		ctx.remove = false;
+		ctx.check = NULL;
+	}
+	if (ret == KNOT_EOK) {
+		ret = interpret_zone(&zdiff, &ctx);
+	}
 	*upd_count += trie_weight(u->upd);
 	pthread_mutex_unlock(&u->mutex);
 	return ret;
@@ -213,11 +243,14 @@ static int prop_verify(zone_node_t *node, cat_upd_ctx_t *ctx)
 int catalog_zone_verify(const struct zone_contents *zone)
 {
 	cat_upd_ctx_t ctx = { NULL, zone, knot_dname_labels(zone->apex->owner, NULL),
-	                      false, NULL, member_verify, prop_verify };
+	                      false, false, NULL, member_verify, prop_verify };
 
 	if (!check_zone_version(zone)) {
 		return KNOT_EZONEINVAL;
 	}
 
-	return interpret_zone(zone, &ctx);
+	zone_diff_t zdiff;
+	zone_diff_from_zone(&zdiff, zone);
+
+	return interpret_zone(&zdiff, &ctx);
 }
diff --git a/src/knot/catalog/interpret.h b/src/knot/catalog/interpret.h
index 94a95fd71a9ed26677e3e5282a89b0d9b3ea8a28..20928b797b5a893ba74abf338ec2b36fa2128ad2 100644
--- a/src/knot/catalog/interpret.h
+++ b/src/knot/catalog/interpret.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -19,6 +19,7 @@
 #include "knot/catalog/catalog_update.h"
 
 struct zone_contents;
+struct zone_diff;
 
 /*!
  * \brief Validate if given zone is valid catalog.
@@ -36,6 +37,7 @@ int catalog_zone_verify(const struct zone_contents *zone);
  *
  * \param u                  Catalog update to be updated.
  * \param zone               Zone contents to be searched for member PTR records.
+ * \param zone_diff          Zone diff to interpret for removals and additions.
  * \param complete_contents  Complete zone contents (zone might be from a changeset).
  * \param remove             Add removals of found member zones.
  * \param check              Optional: existing catalog database to be checked for existence
@@ -45,5 +47,6 @@ int catalog_zone_verify(const struct zone_contents *zone);
  * \return KNOT_E*
  */
 int catalog_update_from_zone(catalog_update_t *u, struct zone_contents *zone,
+                             const struct zone_diff *zone_diff,
                              const struct zone_contents *complete_contents,
                              bool remove, catalog_t *check, ssize_t *upd_count);
diff --git a/src/knot/events/handlers/dnssec.c b/src/knot/events/handlers/dnssec.c
index 67aecf6ad41c77cf5c4a6468bc4cc6bc42cf1ff8..4b2d9e3fc2806417629fff96d2e0e463271c3040 100644
--- a/src/knot/events/handlers/dnssec.c
+++ b/src/knot/events/handlers/dnssec.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -92,7 +92,7 @@ int event_dnssec(conf_t *conf, zone_t *zone)
 	}
 
 	zone_update_t up;
-	int ret = zone_update_init(&up, zone, UPDATE_INCREMENTAL);
+	int ret = zone_update_init(&up, zone, UPDATE_INCREMENTAL | UPDATE_NO_CHSET);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
diff --git a/src/knot/events/handlers/refresh.c b/src/knot/events/handlers/refresh.c
index 063621ada281224bfcdabe3be90c0db5f0fdfcbe..0855b5c506b5cc2adb42e4bb2b83fe950cbf8e29 100644
--- a/src/knot/events/handlers/refresh.c
+++ b/src/knot/events/handlers/refresh.c
@@ -502,7 +502,7 @@ static int ixfr_finalize(struct refresh_data *data)
 	}
 
 	zone_update_t up = { 0 };
-	int ret = zone_update_init(&up, data->zone, UPDATE_INCREMENTAL | UPDATE_STRICT);
+	int ret = zone_update_init(&up, data->zone, UPDATE_INCREMENTAL | UPDATE_STRICT | UPDATE_NO_CHSET);
 	if (ret != KNOT_EOK) {
 		data->fallback_axfr = false;
 		data->fallback->remote = false;
diff --git a/src/knot/events/handlers/update.c b/src/knot/events/handlers/update.c
index 2b17375b0be04887c7187d6e4bcbebdc2bb84ed7..51d7940e01195bb8c54b7c50e519cc0fd08d5a38 100644
--- a/src/knot/events/handlers/update.c
+++ b/src/knot/events/handlers/update.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -138,7 +138,7 @@ static int process_normal(conf_t *conf, zone_t *zone, list_t *requests)
 
 	// Init zone update structure
 	zone_update_t up;
-	int ret = zone_update_init(&up, zone, UPDATE_INCREMENTAL);
+	int ret = zone_update_init(&up, zone, UPDATE_INCREMENTAL | UPDATE_NO_CHSET);
 	if (ret != KNOT_EOK) {
 		set_rcodes(requests, KNOT_RCODE_SERVFAIL);
 		return ret;
diff --git a/src/knot/journal/journal_basic.c b/src/knot/journal/journal_basic.c
index 0285630ed34a7c59db55e1f2148e94dc61274bec..825130a243fff2f6074e971aa076d2f22eea968e 100644
--- a/src/knot/journal/journal_basic.c
+++ b/src/knot/journal/journal_basic.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -27,12 +27,12 @@ MDB_val journal_changeset_id_to_key(bool zone_in_journal, uint32_t serial, const
 	}
 }
 
-MDB_val journal_changeset_to_chunk_key(const changeset_t *ch, uint32_t chunk_id)
+MDB_val journal_make_chunk_key(const knot_dname_t *apex, uint32_t ch_from, bool zij, uint32_t chunk_id)
 {
-	if (ch->soa_from == NULL) {
-		return knot_lmdb_make_key("NISI", ch->add->apex->owner, (uint32_t)0, "bootstrap", chunk_id);
+	if (zij) {
+		return knot_lmdb_make_key("NISI", apex, (uint32_t)0, "bootstrap", chunk_id);
 	} else {
-		return knot_lmdb_make_key("NIII", ch->add->apex->owner, (uint32_t)0, changeset_from(ch), chunk_id);
+		return knot_lmdb_make_key("NIII", apex, (uint32_t)0, ch_from, chunk_id);
 	}
 }
 
diff --git a/src/knot/journal/journal_basic.h b/src/knot/journal/journal_basic.h
index 9234de68079d64784b26574f1e269f425bdce655..8804d7bdf5cc61147a88c8778e3c9ace3d14fcf4 100644
--- a/src/knot/journal/journal_basic.h
+++ b/src/knot/journal/journal_basic.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -52,12 +52,14 @@ MDB_val journal_changeset_id_to_key(bool zone_in_journal, uint32_t serial, const
 /*!
  * \brief Create a database key for changeset chunk.
  *
- * \param ch         Corresponding changeset (perhaps to be stored).
+ * \param apex       Zone apex owner name.
+ * \param ch_from    Serial "from" of the stored changeset.
+ * \param zij        Zone-in-journal is stored.
  * \param chunk_id   Ordinal number of this changeset's chunk.
  *
  * \return DB key. 'mv_data' shall be freed later. 'mv_data' is NULL on failure.
  */
-MDB_val journal_changeset_to_chunk_key(const changeset_t *ch, uint32_t chunk_id);
+MDB_val journal_make_chunk_key(const knot_dname_t *apex, uint32_t ch_from, bool zij, uint32_t chunk_id);
 
 /*!
  * \brief Return a key prefix to operate with all zone-related records.
diff --git a/src/knot/journal/journal_write.c b/src/knot/journal/journal_write.c
index af1e540981e0107d9942b0839d69f1694fdadb5c..ad1247bd7c84355c9cb74ff7dc4d85f9061e5860 100644
--- a/src/knot/journal/journal_write.c
+++ b/src/knot/journal/journal_write.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -22,7 +22,8 @@
 #include "knot/journal/serialization.h"
 #include "libknot/error.h"
 
-static void journal_write_serialize(knot_lmdb_txn_t *txn, serialize_ctx_t *ser, const changeset_t *ch, uint32_t ch_serial_to)
+static void journal_write_serialize(knot_lmdb_txn_t *txn, serialize_ctx_t *ser,
+                                    const knot_dname_t *apex, bool zij, uint32_t ch_from, uint32_t ch_to)
 {
 	MDB_val chunk;
 	uint32_t i = 0;
@@ -34,16 +35,18 @@ static void journal_write_serialize(knot_lmdb_txn_t *txn, serialize_ctx_t *ser,
 		}
 		chunk.mv_size += JOURNAL_HEADER_SIZE;
 		chunk.mv_data = NULL;
-		MDB_val key = journal_changeset_to_chunk_key(ch, i);
+		MDB_val key = journal_make_chunk_key(apex, ch_from, zij, i);
 		if (knot_lmdb_insert(txn, &key, &chunk)) {
-			journal_make_header(chunk.mv_data, ch_serial_to);
+			journal_make_header(chunk.mv_data, ch_to);
 			serialize_chunk(ser, chunk.mv_data + JOURNAL_HEADER_SIZE, chunk.mv_size - JOURNAL_HEADER_SIZE);
 		}
 		free(key.mv_data);
 		i++;
 	}
-	serialize_deinit(ser);
-	// return value is in the txn
+	int ret = serialize_deinit(ser);
+	if (txn->ret == KNOT_EOK) {
+		txn->ret = ret;
+	}
 }
 
 void journal_write_changeset(knot_lmdb_txn_t *txn, const changeset_t *ch)
@@ -53,7 +56,11 @@ void journal_write_changeset(knot_lmdb_txn_t *txn, const changeset_t *ch)
 		txn->ret = KNOT_ENOMEM;
 		return;
 	}
-	journal_write_serialize(txn, ser, ch, changeset_to(ch));
+	if (ch->remove == NULL) {
+		journal_write_serialize(txn, ser, ch->soa_to->owner, true, 0, changeset_to(ch));
+	} else {
+		journal_write_serialize(txn, ser, ch->soa_to->owner, false, changeset_from(ch), changeset_to(ch));
+	}
 }
 
 void journal_write_zone(knot_lmdb_txn_t *txn, const zone_contents_t *z)
@@ -63,10 +70,17 @@ void journal_write_zone(knot_lmdb_txn_t *txn, const zone_contents_t *z)
 		txn->ret = KNOT_ENOMEM;
 		return;
 	}
-	changeset_t fake_ch;
-	fake_ch.soa_from = NULL;
-	fake_ch.add = (zone_contents_t *)z;
-	journal_write_serialize(txn, ser, &fake_ch, zone_contents_serial(z));
+	journal_write_serialize(txn, ser, z->apex->owner, true, 0, zone_contents_serial(z));
+}
+
+void journal_write_zone_diff(knot_lmdb_txn_t *txn, const zone_diff_t *z)
+{
+	serialize_ctx_t *ser = serialize_zone_diff_init(z);
+	if (ser == NULL) {
+		txn->ret = KNOT_ENOMEM;
+		return;
+	}
+	journal_write_serialize(txn, ser, z->apex->owner, false, zone_diff_from(z), zone_diff_to(z));
 }
 
 static bool delete_one(knot_lmdb_txn_t *txn, bool del_zij, uint32_t del_serial,
@@ -242,15 +256,22 @@ int journal_insert_zone(zone_journal_t j, const zone_contents_t *z)
 	return txn.ret;
 }
 
-int journal_insert(zone_journal_t j, const changeset_t *ch, const changeset_t *extra)
+int journal_insert(zone_journal_t j, const changeset_t *ch, const changeset_t *extra,
+                   const zone_diff_t *zdiff)
 {
-	size_t ch_size = changeset_serialized_size(ch);
+	assert(zdiff == NULL || (ch == NULL && extra == NULL));
+
+	size_t ch_size = zdiff == NULL ? changeset_serialized_size(ch) :
+	                                 zone_diff_serialized_size(*zdiff);
 	size_t max_usage = journal_conf_max_usage(j);
 	if (ch_size >= max_usage) {
 		return KNOT_ESPACE;
 	}
-	if (extra != NULL && (changeset_to(extra) != changeset_to(ch) ||
-	     changeset_from(extra) == changeset_from(ch))) {
+
+	uint32_t ch_from = zdiff == NULL ? changeset_from(ch) : zone_diff_from(zdiff);
+	uint32_t ch_to = zdiff == NULL ? changeset_to(ch) : zone_diff_to(zdiff);
+	if (extra != NULL && (changeset_to(extra) != ch_to ||
+	     changeset_from(extra) == ch_from)) {
 		return KNOT_EINVAL;
 	}
 	int ret = knot_lmdb_open(j.db);
@@ -280,7 +301,7 @@ int journal_insert(zone_journal_t j, const changeset_t *ch, const changeset_t *e
 	journal_fix_occupation(j, &txn, &md, max_usage - ch_size, chs_limit - 1);
 
 	// avoid discontinuity
-	if ((md.flags & JOURNAL_SERIAL_TO_VALID) && md.serial_to != changeset_from(ch)) {
+	if ((md.flags & JOURNAL_SERIAL_TO_VALID) && md.serial_to != ch_from) {
 		if (journal_contains(&txn, true, 0, j.zone)) {
 			txn.ret = KNOT_ESEMCHECK;
 		} else {
@@ -290,12 +311,16 @@ int journal_insert(zone_journal_t j, const changeset_t *ch, const changeset_t *e
 	}
 
 	// avoid cycle
-	if (journal_contains(&txn, false, changeset_to(ch), j.zone)) {
+	if (journal_contains(&txn, false, ch_to, j.zone)) {
 		journal_fix_occupation(j, &txn, &md, INT64_MAX, 1);
 	}
 
-	journal_write_changeset(&txn, ch);
-	journal_metadata_after_insert(&md, changeset_from(ch), changeset_to(ch));
+	if (zdiff == NULL) {
+		journal_write_changeset(&txn, ch);
+	} else {
+		journal_write_zone_diff(&txn, zdiff);
+	}
+	journal_metadata_after_insert(&md, ch_from, ch_to);
 
 	if (extra != NULL) {
 		journal_write_changeset(&txn, extra);
diff --git a/src/knot/journal/journal_write.h b/src/knot/journal/journal_write.h
index ad280e46db2a54fdf97452473971cf5305e45e2a..a55fd341bdfe06efaa7340a145e73ae342ee9c0f 100644
--- a/src/knot/journal/journal_write.h
+++ b/src/knot/journal/journal_write.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -18,6 +18,7 @@
 
 #include "knot/journal/journal_basic.h"
 #include "knot/journal/journal_metadata.h"
+#include "knot/journal/serialization.h"
 
 /*!
  * \brief Serialize a changeset into chunks and write it into DB with no checks and metadata update.
@@ -107,6 +108,7 @@ int journal_insert_zone(zone_journal_t j, const zone_contents_t *z);
  * \param j    Zone journal.
  * \param ch   Changeset to be stored.
  * \param extra   Extra changeset to be stored in the role of merged changeset.
+ * \param zdiff   Zone diff to be stored instead of changeset.
  *
  * \note The extra changesetis being stored on zone load, it is basically the diff
  *       between zonefile and loaded zone contents. Afterwards, it will be treated
@@ -115,4 +117,5 @@ int journal_insert_zone(zone_journal_t j, const zone_contents_t *z);
  *
  * \return KNOT_E*
  */
-int journal_insert(zone_journal_t j, const changeset_t *ch, const changeset_t *extra);
+int journal_insert(zone_journal_t j, const changeset_t *ch, const changeset_t *extra,
+                   const zone_diff_t *zdiff);
diff --git a/src/knot/journal/serialization.c b/src/knot/journal/serialization.c
index 5946152defd7a279943b3bff27c81b5e9fb6683d..575848116ecdb819a2d75fdac50a5beefce55f21 100644
--- a/src/knot/journal/serialization.c
+++ b/src/knot/journal/serialization.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -18,7 +18,6 @@
 
 #include "knot/journal/serialization.h"
 #include "knot/zone/zone-tree.h"
-#include "libknot/libknot.h"
 
 #define SERIALIZE_RRSET_INIT (-1)
 #define SERIALIZE_RRSET_DONE ((1L<<16)+1)
@@ -37,10 +36,13 @@ typedef enum {
 #define RRSET_BUF_MAXSIZE 256
 
 struct serialize_ctx {
-	const zone_contents_t *z;
+	zone_diff_t zdiff;
 	zone_tree_it_t zit;
 	zone_node_t *n;
 	uint16_t node_pos;
+	bool zone_diff;
+	bool zone_diff_add;
+	int ret;
 
 	const changeset_t *ch;
 	changeset_iter_t it;
@@ -48,6 +50,7 @@ struct serialize_ctx {
 	long rrset_phase;
 	knot_rrset_t rrset_buf[RRSET_BUF_MAXSIZE];
 	size_t rrset_buf_size;
+	list_t free_rdatasets;
 };
 
 serialize_ctx_t *serialize_init(const changeset_t *ch)
@@ -61,6 +64,7 @@ serialize_ctx_t *serialize_init(const changeset_t *ch)
 	ctx->changeset_phase = ch->soa_from != NULL ? PHASE_SOA_1 : PHASE_SOA_2;
 	ctx->rrset_phase = SERIALIZE_RRSET_INIT;
 	ctx->rrset_buf_size = 0;
+	init_list(&ctx->free_rdatasets);
 
 	return ctx;
 }
@@ -72,10 +76,30 @@ serialize_ctx_t *serialize_zone_init(const zone_contents_t *z)
 		return NULL;
 	}
 
-	ctx->z = z;
+	zone_diff_from_zone(&ctx->zdiff, z);
 	ctx->changeset_phase = PHASE_ZONE_SOA;
 	ctx->rrset_phase = SERIALIZE_RRSET_INIT;
 	ctx->rrset_buf_size = 0;
+	init_list(&ctx->free_rdatasets);
+
+	return ctx;
+}
+
+serialize_ctx_t *serialize_zone_diff_init(const zone_diff_t *z)
+{
+	serialize_ctx_t *ctx = calloc(1, sizeof(*ctx));
+	if (ctx == NULL) {
+		return NULL;
+	}
+
+	ctx->zone_diff = true;
+	ctx->zdiff = *z;
+	zone_diff_reverse(&ctx->zdiff); // start with removals of counterparts
+
+	ctx->changeset_phase = PHASE_ZONE_SOA;
+	ctx->rrset_phase = SERIALIZE_RRSET_INIT;
+	ctx->rrset_buf_size = 0;
+	init_list(&ctx->free_rdatasets);
 
 	return ctx;
 }
@@ -86,19 +110,28 @@ static knot_rrset_t get_next_rrset(serialize_ctx_t *ctx)
 	knot_rrset_init_empty(&res);
 	switch (ctx->changeset_phase) {
 	case PHASE_ZONE_SOA:
-		zone_tree_it_begin(ctx->z->nodes, &ctx->zit);
+		zone_tree_it_begin(&ctx->zdiff.nodes, &ctx->zit);
 		ctx->changeset_phase = PHASE_ZONE_NODES;
-		return node_rrset(ctx->z->apex, KNOT_RRTYPE_SOA);
+		return node_rrset(ctx->zdiff.apex, KNOT_RRTYPE_SOA);
 	case PHASE_ZONE_NODES:
 	case PHASE_ZONE_NSEC3:
 		while (ctx->n == NULL || ctx->node_pos >= ctx->n->rrset_count) {
 			if (zone_tree_it_finished(&ctx->zit)) {
 				zone_tree_it_free(&ctx->zit);
-				if (ctx->changeset_phase == PHASE_ZONE_NSEC3 || zone_tree_is_empty(ctx->z->nsec3_nodes)) {
-					ctx->changeset_phase = PHASE_END;
-					return res;
+				if (ctx->changeset_phase == PHASE_ZONE_NSEC3 ||
+				    zone_tree_is_empty(&ctx->zdiff.nsec3s)) {
+					if (ctx->zone_diff && !ctx->zone_diff_add) {
+						ctx->zone_diff_add = true;
+						zone_diff_reverse(&ctx->zdiff);
+						zone_tree_it_begin(&ctx->zdiff.nodes, &ctx->zit);
+						ctx->changeset_phase = PHASE_ZONE_NODES;
+						return node_rrset(ctx->zdiff.apex, KNOT_RRTYPE_SOA);
+					} else {
+						ctx->changeset_phase = PHASE_END;
+						return res;
+					}
 				} else {
-					zone_tree_it_begin(ctx->z->nsec3_nodes, &ctx->zit);
+					zone_tree_it_begin(&ctx->zdiff.nsec3s, &ctx->zit);
 					ctx->changeset_phase = PHASE_ZONE_NSEC3;
 				}
 			}
@@ -107,9 +140,27 @@ static knot_rrset_t get_next_rrset(serialize_ctx_t *ctx)
 			ctx->node_pos = 0;
 		}
 		res = node_rrset_at(ctx->n, ctx->node_pos++);
-		if (ctx->n == ctx->z->apex && res.type == KNOT_RRTYPE_SOA) {
+		if (ctx->n == ctx->zdiff.apex && res.type == KNOT_RRTYPE_SOA) {
 			return get_next_rrset(ctx);
 		}
+		if (ctx->zone_diff) {
+			knot_rrset_t counter_rr = node_rrset(binode_counterpart(ctx->n), res.type);
+			if (counter_rr.ttl == res.ttl && !knot_rrset_empty(&counter_rr)) {
+				if (knot_rdataset_subset(&res.rrs, &counter_rr.rrs)) {
+					return get_next_rrset(ctx);
+				}
+				knot_rdataset_t rd_copy;
+				ctx->ret = knot_rdataset_copy(&rd_copy, &res.rrs, NULL);
+				if (ctx->ret == KNOT_EOK) {
+					knot_rdataset_subtract(&rd_copy, &counter_rr.rrs, NULL);
+					ptrlist_add(&ctx->free_rdatasets, rd_copy.rdata, NULL);
+					res.rrs = rd_copy;
+					assert(!knot_rrset_empty(&res));
+				} else {
+					ctx->changeset_phase = PHASE_END;
+				}
+			}
+		}
 		return res;
 	case PHASE_SOA_1:
 		changeset_iter_rem(&ctx->it, ctx->ch);
@@ -152,6 +203,16 @@ void serialize_prepare(serialize_ctx_t *ctx, size_t thresh_size,
 	if (ctx->rrset_buf_size > 0) {
 		ctx->rrset_buf[0] = ctx->rrset_buf[ctx->rrset_buf_size - 1];
 		ctx->rrset_buf_size = 1;
+
+		// memory optimization: free all buffered rrsets except last one
+		ptrnode_t *n, *next;
+		WALK_LIST_DELSAFE(n, next, ctx->free_rdatasets) {
+			if (n != TAIL(ctx->free_rdatasets)) {
+				free(n->d);
+				rem_node(&n->n);
+				free(n);
+			}
+		}
 	} else {
 		ctx->rrset_buf[0] = get_next_rrset(ctx);
 		if (ctx->changeset_phase == PHASE_END) {
@@ -237,7 +298,7 @@ bool serialize_unfinished(serialize_ctx_t *ctx)
 	return ctx->changeset_phase < PHASE_END;
 }
 
-void serialize_deinit(serialize_ctx_t *ctx)
+int serialize_deinit(serialize_ctx_t *ctx)
 {
 	if (ctx->it.node != NULL) {
 		changeset_iter_clear(&ctx->it);
@@ -245,7 +306,15 @@ void serialize_deinit(serialize_ctx_t *ctx)
 	if (ctx->zit.tree != NULL) {
 		zone_tree_it_free(&ctx->zit);
 	}
+	ptrnode_t *n, *next;
+	WALK_LIST_DELSAFE(n, next, ctx->free_rdatasets) {
+		free(n->d);
+		rem_node(&n->n);
+		free(n);
+	}
+	int ret = ctx->ret;
 	free(ctx);
+	return ret;
 }
 
 static uint64_t rrset_binary_size(const knot_rrset_t *rrset)
@@ -268,6 +337,40 @@ static uint64_t rrset_binary_size(const knot_rrset_t *rrset)
 	return size;
 }
 
+static size_t node_diff_size(zone_node_t *node)
+{
+	size_t res = 0;
+	knot_rrset_t rr, counter_rr;
+	for (int i = 0; i < node->rrset_count; i++) {
+		rr = node_rrset_at(node, i);
+		counter_rr = node_rrset(binode_counterpart(node), rr.type);
+		if (!knot_rrset_equal(&rr, &counter_rr, true)) {
+			res += rrset_binary_size(&rr);
+		}
+	}
+	return res;
+}
+
+size_t zone_diff_serialized_size(zone_diff_t diff)
+{
+	size_t res = 0;
+	for (int i = 0; i < 2; i++) {
+		zone_diff_reverse(&diff);
+		zone_tree_it_t it = { 0 };
+		int ret = zone_tree_it_double_begin(&diff.nodes, diff.nsec3s.trie != NULL ?
+		                                                 &diff.nsec3s : NULL, &it);
+		if (ret != KNOT_EOK) {
+			return 0;
+		}
+		while (!zone_tree_it_finished(&it)) {
+			res += node_diff_size(zone_tree_it_val(&it));
+			zone_tree_it_next(&it);
+		}
+		zone_tree_it_free(&it);
+	}
+	return res;
+}
+
 size_t changeset_serialized_size(const changeset_t *ch)
 {
 	if (ch == NULL) {
diff --git a/src/knot/journal/serialization.h b/src/knot/journal/serialization.h
index b4f80a364ff8b64bf944cbee1f12ff91e5454e00..621dcdb5f01e4f143254c716f21b39c40086daed 100644
--- a/src/knot/journal/serialization.h
+++ b/src/knot/journal/serialization.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -17,10 +17,46 @@
 #pragma once
 
 #include <stdint.h>
+
 #include "libknot/rrset.h"
+#include "libknot/rrtype/soa.h"
 #include "knot/updates/changesets.h"
 #include "contrib/wire_ctx.h"
 
+typedef struct zone_diff {
+	zone_tree_t nodes;
+	zone_tree_t nsec3s;
+	zone_node_t *apex;
+} zone_diff_t;
+
+inline static void zone_diff_reverse(zone_diff_t *diff)
+{
+	diff->nodes.flags ^= ZONE_TREE_BINO_SECOND;
+	diff->nsec3s.flags ^= ZONE_TREE_BINO_SECOND;
+	diff->apex = binode_counterpart(diff->apex);
+}
+
+inline static void zone_diff_from_zone(zone_diff_t *diff, const zone_contents_t *z)
+{
+	diff->nodes = *z->nodes;
+	if (z->nsec3_nodes != NULL) {
+		diff->nsec3s = *z->nsec3_nodes;
+	} else {
+		memset(&diff->nsec3s, 0, sizeof(diff->nsec3s));
+	}
+	diff->apex = z->apex;
+}
+
+inline static uint32_t zone_diff_to(const zone_diff_t *diff)
+{
+	return knot_soa_serial(node_rdataset(diff->apex, KNOT_RRTYPE_SOA)->rdata);
+}
+
+inline static uint32_t zone_diff_from(const zone_diff_t *diff)
+{
+	return knot_soa_serial(node_rdataset(binode_counterpart(diff->apex), KNOT_RRTYPE_SOA)->rdata);
+}
+
 typedef struct serialize_ctx serialize_ctx_t;
 
 /*!
@@ -41,6 +77,15 @@ serialize_ctx_t *serialize_init(const changeset_t *ch);
  */
 serialize_ctx_t *serialize_zone_init(const zone_contents_t *z);
 
+/*!
+ * \brief Init serialization context.
+ *
+ * \param z   Zone with binodes being updated.
+ *
+ * \return Context.
+ */
+serialize_ctx_t *serialize_zone_diff_init(const zone_diff_t *z);
+
 /*!
  * \brief Pre-check and space computation before serializing a chunk.
  *
@@ -67,8 +112,23 @@ void serialize_chunk(serialize_ctx_t *ctx, uint8_t *chunk, size_t chunk_size);
  *         to be serialized into next chunk(s) yet. */
 bool serialize_unfinished(serialize_ctx_t *ctx);
 
-/*! \brief Free serialization context. */
-void serialize_deinit(serialize_ctx_t *ctx);
+/*!
+ * \brief Free serialization context.
+ *
+ * \return KNOT_E* if there were errors during serialization.
+ */
+int serialize_deinit(serialize_ctx_t *ctx);
+
+/*!
+ * \brief Returns size of serialized changeset from zone diff.
+ *
+ * \warning Not accurate! This is an upper bound, suitable for policy enforcement etc.
+ *
+ * \param[in] diff    Zone diff structure to create changeset from.
+ *
+ * \return Size of the resulting changeset.
+ */
+size_t zone_diff_serialized_size(zone_diff_t diff);
 
 /*!
  * \brief Returns size of changeset in serialized form.
diff --git a/src/knot/updates/ddns.c b/src/knot/updates/ddns.c
index 6648ca851e3537e43bba86966e7a71a4ac2ca224..7e6ba29610e7e09a7d3938c202097edca97c6883 100644
--- a/src/knot/updates/ddns.c
+++ b/src/knot/updates/ddns.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -24,8 +24,6 @@
 #include "libknot/libknot.h"
 #include "contrib/ucw/lists.h"
 
-/* ----------------------------- prereq check ------------------------------- */
-
 /*!< \brief Clears prereq RRSet list. */
 static void rrset_list_clear(list_t *l)
 {
@@ -173,7 +171,7 @@ static bool rrset_empty(const knot_rrset_t *rrset)
 	}
 }
 
-/*< \brief Returns true if DDNS should deny updating DNSSEC-related record. */
+/*!< \brief Returns true if DDNS should deny updating DNSSEC-related record. */
 static bool is_dnssec_protected(uint16_t type, bool is_apex)
 {
 	switch (type) {
@@ -239,10 +237,6 @@ static int process_prereq(const knot_rrset_t *rrset, uint16_t qclass,
 	}
 }
 
-/* --------------------------- DDNS processing ------------------------------ */
-
-/* --------------------- true/false helper functions ------------------------ */
-
 static inline bool is_addition(const knot_rrset_t *rr)
 {
 	return rr->rclass == KNOT_CLASS_IN;
@@ -320,49 +314,26 @@ static bool skip_soa(const knot_rrset_t *rr, int64_t sn)
 	return false;
 }
 
-/* ---------------------- changeset manipulation ---------------------------- */
-
 /*!< \brief Replaces possible singleton RR type in changeset. */
-static bool singleton_replaced(changeset_t *changeset,
-                               const knot_rrset_t *rr)
+static bool singleton_replaced(zone_update_t *update, const knot_rrset_t *rr)
 {
 	if (!should_replace(rr)) {
 		return false;
 	}
 
-	zone_node_t *n = zone_contents_find_node_for_rr(changeset->add, rr);
-	if (n == NULL) {
-		return false;
-	}
-
-	knot_rdataset_t *rrs = node_rdataset(n, rr->type);
-	if (rrs == NULL) {
-		return false;
-	}
-
-	// Replace singleton RR.
-	knot_rdataset_clear(rrs, NULL);
-	node_remove_rdataset(n, rr->type);
-	node_add_rrset(n, rr, NULL);
-
-	return true;
+	return zone_update_remove_rrset(update, rr->owner, rr->type) == KNOT_EOK;
 }
 
 /*!< \brief Adds RR into add section of changeset if it is deemed worthy. */
-static int add_rr_to_changeset(const knot_rrset_t *rr,
-                            zone_update_t *update)
+static int add_rr_to_changeset(const knot_rrset_t *rr, zone_update_t *update)
 {
-	if (singleton_replaced(&update->change, rr)) {
+	if (singleton_replaced(update, rr)) {
 		return KNOT_EOK;
 	}
 
 	return zone_update_add(update, rr);
 }
 
-/* ------------------------ RR processing logic ----------------------------- */
-
-/* --------------------------- RR additions --------------------------------- */
-
 /*!< \brief Processes CNAME addition (replace or ignore) */
 static int process_add_cname(const zone_node_t *node,
                              const knot_rrset_t *rr,
@@ -473,8 +444,6 @@ static int process_add(const knot_rrset_t *rr,
 	}
 }
 
-/* --------------------------- RR deletions --------------------------------- */
-
 /*!< \brief Removes single RR from zone. */
 static int process_rem_rr(const knot_rrset_t *rr,
                           const zone_node_t *node,
@@ -586,8 +555,6 @@ static int process_remove(const knot_rrset_t *rr,
 	}
 }
 
-/* --------------------------- validity checks ------------------------------ */
-
 /*!< \brief Checks whether addition has not violated DNAME rules. */
 static bool sem_check(const knot_rrset_t *rr, const zone_node_t *zone_node,
                       zone_update_t *update)
@@ -695,8 +662,6 @@ static uint16_t ret_to_rcode(int ret)
 	}
 }
 
-/* ---------------------------------- API ----------------------------------- */
-
 int ddns_process_prereqs(const knot_pkt_t *query, zone_update_t *update,
                          uint16_t *rcode)
 {
diff --git a/src/knot/updates/zone-update.c b/src/knot/updates/zone-update.c
index 944a6f9ceaf232e34895813d6b1d506f1e455757..3c56c81521662558aa6057a8b7da0771b4376c02 100644
--- a/src/knot/updates/zone-update.c
+++ b/src/knot/updates/zone-update.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -361,7 +361,11 @@ const knot_rdataset_t *zone_update_to(zone_update_t *update)
 		return NULL;
 	}
 
-	if (update->flags & UPDATE_FULL) {
+	if (update->flags & UPDATE_NO_CHSET) {
+		zone_diff_t diff = { .apex = update->new_cont->apex };
+		return zone_diff_to(&diff) == zone_diff_from(&diff) ?
+		       NULL : node_rdataset(update->new_cont->apex, KNOT_RRTYPE_SOA);
+	} else if (update->flags & UPDATE_FULL) {
 		const zone_node_t *apex = update->new_cont->apex;
 		return node_rdataset(apex, KNOT_RRTYPE_SOA);
 	} else {
@@ -459,10 +463,11 @@ int zone_update_add(zone_update_t *update, const knot_rrset_t *rrset)
 
 	if (update->flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) {
 		int ret = solve_add_different_ttl(update, rrset);
-		if (ret == KNOT_EOK) {
+		if (ret == KNOT_EOK && !(update->flags & UPDATE_NO_CHSET)) {
 			ret = changeset_add_addition(&update->change, rrset, CHANGESET_CHECK);
 		}
 		if (ret == KNOT_EOK && (update->flags & UPDATE_EXTRA_CHSET)) {
+			assert(!(update->flags & UPDATE_NO_CHSET));
 			ret = changeset_add_addition(&update->extra_ch, rrset, CHANGESET_CHECK);
 		}
 		if (ret != KNOT_EOK) {
@@ -474,7 +479,7 @@ int zone_update_add(zone_update_t *update, const knot_rrset_t *rrset)
 		if (rrset->type == KNOT_RRTYPE_SOA) {
 			// replace previous SOA
 			int ret = apply_replace_soa(update->a_ctx, rrset);
-			if (ret != KNOT_EOK) {
+			if (ret != KNOT_EOK && !(update->flags & UPDATE_NO_CHSET)) {
 				changeset_remove_addition(&update->change, rrset);
 			}
 			return ret;
@@ -482,7 +487,9 @@ int zone_update_add(zone_update_t *update, const knot_rrset_t *rrset)
 
 		int ret = apply_add_rr(update->a_ctx, rrset);
 		if (ret != KNOT_EOK) {
-			changeset_remove_addition(&update->change, rrset);
+			if (!(update->flags & UPDATE_NO_CHSET)) {
+				changeset_remove_addition(&update->change, rrset);
+			}
 			return ret;
 		}
 
@@ -525,9 +532,11 @@ int zone_update_remove(zone_update_t *update, const knot_rrset_t *rrset)
 		return KNOT_EOK;
 	}
 
-	if ((update->flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) && rrset->type != KNOT_RRTYPE_SOA) {
+	if ((update->flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) &&
+	    rrset->type != KNOT_RRTYPE_SOA && !(update->flags & UPDATE_NO_CHSET)) {
 		int ret = changeset_add_removal(&update->change, rrset, CHANGESET_CHECK);
 		if (ret == KNOT_EOK && (update->flags & UPDATE_EXTRA_CHSET)) {
+			assert(!(update->flags & UPDATE_NO_CHSET));
 			ret = changeset_add_removal(&update->extra_ch, rrset, CHANGESET_CHECK);
 		}
 		if (ret != KNOT_EOK) {
@@ -543,7 +552,9 @@ int zone_update_remove(zone_update_t *update, const knot_rrset_t *rrset)
 
 		int ret = apply_remove_rr(update->a_ctx, rrset);
 		if (ret != KNOT_EOK) {
-			changeset_remove_removal(&update->change, rrset);
+			if (!(update->flags & UPDATE_NO_CHSET)) {
+				changeset_remove_removal(&update->change, rrset);
+			}
 			return ret;
 		}
 
@@ -667,15 +678,28 @@ int zone_update_increment_soa(zone_update_t *update, conf_t *conf)
 	return set_new_soa(update, conf_opt(&val));
 }
 
+static void get_zone_diff(zone_diff_t *zdiff, zone_update_t *up)
+{
+	zdiff->nodes = *up->a_ctx->node_ptrs;
+	zdiff->nsec3s = *up->a_ctx->nsec3_ptrs;
+	zdiff->apex = up->new_cont->apex;
+}
+
 static int commit_journal(conf_t *conf, zone_update_t *update)
 {
 	conf_val_t val = conf_zone_get(conf, C_JOURNAL_CONTENT, update->zone->name);
 	unsigned content = conf_opt(&val);
 	int ret = KNOT_EOK;
-	if ((update->flags & UPDATE_INCREMENTAL) ||
-	    (update->flags & UPDATE_HYBRID)) {
+	if (update->flags & UPDATE_NO_CHSET) {
+		zone_diff_t diff;
+		get_zone_diff(&diff, update);
+		if (content != JOURNAL_CONTENT_NONE && !zone_update_no_change(update)) {
+			ret = zone_diff_store(conf, update->zone, &diff);
+		}
+	} else if ((update->flags & UPDATE_INCREMENTAL) ||
+	           (update->flags & UPDATE_HYBRID)) {
 		changeset_t *extra = (update->flags & UPDATE_EXTRA_CHSET) ? &update->extra_ch : NULL;
-		if (content != JOURNAL_CONTENT_NONE && !changeset_empty(&update->change)) {
+		if (content != JOURNAL_CONTENT_NONE && !zone_update_no_change(update)) {
 			ret = zone_change_store(conf, update->zone, &update->change, extra);
 		}
 	} else {
@@ -692,7 +716,7 @@ static int commit_incremental(conf_t *conf, zone_update_t *update)
 {
 	assert(update);
 
-	if (zone_update_to(update) == NULL && !changeset_empty(&update->change)) {
+	if (zone_update_to(update) == NULL && !zone_update_no_change(update)) {
 		/* No SOA in the update, create one according to the current policy */
 		int ret = zone_update_increment_soa(update, conf);
 		if (ret != KNOT_EOK) {
@@ -732,13 +756,19 @@ static int update_catalog(conf_t *conf, zone_update_t *update)
 	}
 
 	ssize_t upd_count = 0;
-	if ((update->flags & UPDATE_INCREMENTAL)) {
+	if ((update->flags & UPDATE_NO_CHSET)) {
+		zone_diff_t diff;
+		get_zone_diff(&diff, update);
 		ret = catalog_update_from_zone(zone_catalog_upd(update->zone),
-		                               update->change.remove, update->new_cont,
+		                               NULL, &diff, update->new_cont,
+		                               false, zone_catalog(update->zone), &upd_count);
+	} else if ((update->flags & UPDATE_INCREMENTAL)) {
+		ret = catalog_update_from_zone(zone_catalog_upd(update->zone),
+		                               update->change.remove, NULL, update->new_cont,
 		                               true, zone_catalog(update->zone), &upd_count);
 		if (ret == KNOT_EOK) {
 			ret = catalog_update_from_zone(zone_catalog_upd(update->zone),
-			                               update->change.add, update->new_cont,
+			                               update->change.add, NULL, update->new_cont,
 			                               false, NULL, &upd_count);
 		}
 	} else {
@@ -747,7 +777,7 @@ static int update_catalog(conf_t *conf, zone_update_t *update)
 		                             update->zone->name, &upd_count);
 		if (ret == KNOT_EOK) {
 			ret = catalog_update_from_zone(zone_catalog_upd(update->zone),
-			                               update->new_cont, update->new_cont,
+			                               update->new_cont, NULL, update->new_cont,
 			                               false, NULL, &upd_count);
 		}
 	}
@@ -879,7 +909,7 @@ int zone_update_commit(conf_t *conf, zone_update_t *update)
 
 	int ret = KNOT_EOK;
 
-	if ((update->flags & UPDATE_INCREMENTAL) && changeset_empty(&update->change)) {
+	if ((update->flags & UPDATE_INCREMENTAL) && zone_update_no_change(update)) {
 		zone_update_clear(update);
 		return KNOT_EOK;
 	}
@@ -1003,7 +1033,11 @@ bool zone_update_no_change(zone_update_t *update)
 		return true;
 	}
 
-	if (update->flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) {
+	if (update->flags & UPDATE_NO_CHSET) {
+		zone_diff_t diff;
+		get_zone_diff(&diff, update);
+		return (zone_diff_serialized_size(diff) == 0);
+	} else if (update->flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) {
 		return changeset_empty(&update->change);
 	} else {
 		/* This branch does not make much sense and FULL update will most likely
@@ -1012,6 +1046,17 @@ bool zone_update_no_change(zone_update_t *update)
 	}
 }
 
+static bool zone_diff_rdataset(const zone_contents_t *c, uint16_t rrtype)
+{
+	const knot_rdataset_t *a = node_rdataset(binode_counterpart(c->apex), rrtype);
+	const knot_rdataset_t *b = node_rdataset(c->apex, rrtype);
+	if ((a == NULL && b == NULL) || (a != NULL && b != NULL && a->rdata == b->rdata)) {
+		return false;
+	} else {
+		return !knot_rdataset_eq(a, b);
+	}
+}
+
 static bool contents_have_dnskey(const zone_contents_t *contents)
 {
 	if (contents == NULL) {
@@ -1025,7 +1070,11 @@ static bool contents_have_dnskey(const zone_contents_t *contents)
 
 bool zone_update_changes_dnskey(zone_update_t *update)
 {
-	if (update->flags & UPDATE_FULL) {
+	if (update->flags & UPDATE_NO_CHSET) {
+		return (zone_diff_rdataset(update->new_cont, KNOT_RRTYPE_DNSKEY) ||
+		        zone_diff_rdataset(update->new_cont, KNOT_RRTYPE_CDNSKEY) ||
+		        zone_diff_rdataset(update->new_cont, KNOT_RRTYPE_CDS));
+	} else if (update->flags & UPDATE_FULL) {
 		return contents_have_dnskey(update->new_cont);
 	} else {
 		return (contents_have_dnskey(update->change.remove) ||
diff --git a/src/knot/updates/zone-update.h b/src/knot/updates/zone-update.h
index 8a2d7803b5269920afb4d81ce63b36873fbf0a74..1cda54b262b798a4135a8ad09e9913d087a9fe5b 100644
--- a/src/knot/updates/zone-update.h
+++ b/src/knot/updates/zone-update.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -56,6 +56,7 @@ typedef enum {
 	UPDATE_STRICT         = 1 << 4, /*!< Apply changes strictly, i.e. fail when removing nonexistent RR. */
 	UPDATE_EXTRA_CHSET    = 1 << 6, /*!< Extra changeset in use, to store diff btwn zonefile and final contents. */
 	UPDATE_CHANGED_NSEC   = 1 << 7, /*!< This incremental update affects NSEC or NSEC3 nodes in zone. */
+	UPDATE_NO_CHSET       = 1 << 8, /*!< Avoid using changeset and serialize to journal from diff of bi-nodes. */
 } zone_update_flags_t;
 
 /*!
@@ -153,6 +154,8 @@ const knot_rdataset_t *zone_update_from(zone_update_t *update);
  * \param update  Zone update.
  *
  * \return   NULL if no new SOA has been added, new SOA otherwise.
+ *
+ * \todo Refactor this function according to its use.
  */
 const knot_rdataset_t *zone_update_to(zone_update_t *update);
 
diff --git a/src/knot/zone/zone-tree.c b/src/knot/zone/zone-tree.c
index 275f5069c0d2a84e742d2bdff5c3d9fadd3e8294..87dde1878459dde5b358a2d1557515f0921c155a 100644
--- a/src/knot/zone/zone-tree.c
+++ b/src/knot/zone/zone-tree.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -351,9 +351,16 @@ int zone_tree_it_double_begin(zone_tree_t *first, zone_tree_t *second, zone_tree
 		if (it->it == NULL) {
 			return KNOT_ENOMEM;
 		}
-		it->tree = first;
-		it->binode_second = ((first->flags & ZONE_TREE_BINO_SECOND) ? 1 : 0);
-		it->next_tree = second;
+		if (trie_it_finished(it->it) && second != NULL) { // first tree is empty
+			trie_it_free(it->it);
+			it->it = trie_it_begin(second->trie);
+			it->tree = second;
+			it->next_tree = NULL;
+		} else {
+			it->tree = first;
+			it->next_tree = second;
+		}
+		it->binode_second = ((it->tree->flags & ZONE_TREE_BINO_SECOND) ? 1 : 0);
 	}
 	return KNOT_EOK;
 }
diff --git a/src/knot/zone/zone-tree.h b/src/knot/zone/zone-tree.h
index bf8a6600d48dffef7bd99d20f8d2a491018a5695..384e87efdfc7cd8a463d15c7a3684d7d8dbaf6d2 100644
--- a/src/knot/zone/zone-tree.h
+++ b/src/knot/zone/zone-tree.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -86,7 +86,7 @@ zone_tree_t *zone_tree_shallow_copy(zone_tree_t *from);
  */
 inline static size_t zone_tree_count(const zone_tree_t *tree)
 {
-	if (tree == NULL) {
+	if (tree == NULL || tree->trie == NULL) {
 		return 0;
 	}
 
diff --git a/src/knot/zone/zone.c b/src/knot/zone/zone.c
index 321f3f5e66661a8b48ddd325e295a61482e133a0..8d412613012ce3c6053c04f896107f188b486844 100644
--- a/src/knot/zone/zone.c
+++ b/src/knot/zone/zone.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -280,14 +280,36 @@ int zone_change_store(conf_t *conf, zone_t *zone, changeset_t *change, changeset
 
 	zone_journal_t j = { zone_journaldb(zone), zone->name, conf };
 
-	int ret = journal_insert(j, change, extra);
+	int ret = journal_insert(j, change, extra, NULL);
 	if (ret == KNOT_EBUSY) {
 		log_zone_notice(zone->name, "journal is full, flushing");
 
 		/* Transaction rolled back, journal released, we may flush. */
 		ret = flush_journal(conf, zone, true, false);
 		if (ret == KNOT_EOK) {
-			ret = journal_insert(j, change, extra);
+			ret = journal_insert(j, change, extra, NULL);
+		}
+	}
+
+	return ret;
+}
+
+int zone_diff_store(conf_t *conf, zone_t *zone, const zone_diff_t *diff)
+{
+	if (conf == NULL || zone == NULL || diff == NULL) {
+		return KNOT_EINVAL;
+	}
+
+	zone_journal_t j = { zone_journaldb(zone), zone->name, conf };
+
+	int ret = journal_insert(j, NULL, NULL, diff);
+	if (ret == KNOT_EBUSY) {
+		log_zone_notice(zone->name, "journal is full, flushing");
+
+		/* Transaction rolled back, journal released, we may flush. */
+		ret = flush_journal(conf, zone, true, false);
+		if (ret == KNOT_EOK) {
+			ret = journal_insert(j, NULL, NULL, diff);
 		}
 	}
 
diff --git a/src/knot/zone/zone.h b/src/knot/zone/zone.h
index 7a8c2ac0a567eb67b190b08373c2311855c991cd..6623c43848456f2c290cb8f291515bb9d3dbc9a5 100644
--- a/src/knot/zone/zone.h
+++ b/src/knot/zone/zone.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -21,6 +21,7 @@
 #include "knot/conf/conf.h"
 #include "knot/conf/confio.h"
 #include "knot/journal/journal_basic.h"
+#include "knot/journal/serialization.h"
 #include "knot/events/events.h"
 #include "knot/updates/changesets.h"
 #include "knot/zone/contents.h"
@@ -153,6 +154,7 @@ inline static zone_journal_t zone_journal(zone_t *zone)
 }
 
 int zone_change_store(conf_t *conf, zone_t *zone, changeset_t *change, changeset_t *extra);
+int zone_diff_store(conf_t *conf, zone_t *zone, const zone_diff_t *diff);
 int zone_changes_clear(conf_t *conf, zone_t *zone);
 int zone_in_journal_store(conf_t *conf, zone_t *zone, zone_contents_t *new_contents);
 
diff --git a/src/libknot/rdataset.c b/src/libknot/rdataset.c
index 6e5c81c8f06408e4b80c9bd8e8489a96aa3b8707..0b9b4fd8c611358a74ad1f62e3733cf7965966b8 100644
--- a/src/libknot/rdataset.c
+++ b/src/libknot/rdataset.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -246,6 +246,24 @@ bool knot_rdataset_member(const knot_rdataset_t *rrs, const knot_rdata_t *rr)
 	return false;
 }
 
+_public_
+bool knot_rdataset_subset(const knot_rdataset_t *subset, const knot_rdataset_t *of)
+{
+	if (subset == NULL || (of != NULL && subset->rdata == of->rdata)) {
+		return true;
+	}
+
+	knot_rdata_t *rd = subset->rdata;
+	for (uint16_t i = 0; i < subset->count; ++i) {
+		if (!knot_rdataset_member(of, rd)) {
+			return false;
+		}
+		rd = knot_rdataset_next(rd);
+	}
+
+	return true;
+}
+
 _public_
 int knot_rdataset_merge(knot_rdataset_t *rrs1, const knot_rdataset_t *rrs2,
                         knot_mm_t *mm)
diff --git a/src/libknot/rdataset.h b/src/libknot/rdataset.h
index 5c49847fdd69374990eb4047c626eb0697f6bf1e..f3e65a86e63b49277c1af3a6ddd1d4e9d49eeb0b 100644
--- a/src/libknot/rdataset.h
+++ b/src/libknot/rdataset.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -134,6 +134,17 @@ bool knot_rdataset_eq(const knot_rdataset_t *rrs1, const knot_rdataset_t *rrs2);
  */
 bool knot_rdataset_member(const knot_rdataset_t *rrs, const knot_rdata_t *rr);
 
+/*!
+ * \brief Returns true if \a subset is a sub-set of \a of, false otherwise.
+ *
+ * \param subset  RRS to check.
+ * \param of      RRS to search in.
+ *
+ * \retval true if \a subset is a sub-set of \a of.
+ * \retval false if \a subset is not a sub-set of \a of.
+ */
+bool knot_rdataset_subset(const knot_rdataset_t *subset, const knot_rdataset_t *of);
+
 /*!
  * \brief Merges two RRS into the first one. Second RRS is left intact.
  *        Canonical order is preserved.
diff --git a/tests/knot/test_journal.c b/tests/knot/test_journal.c
index afc824422aa4e0f868cef6e215e7b980f63175a8..748ab02d02622269801a1596fd51e87c2621a612 100644
--- a/tests/knot/test_journal.c
+++ b/tests/knot/test_journal.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -315,7 +315,7 @@ static void test_store_load(const knot_dname_t *apex)
 
 	changeset_t *m_ch = changeset_new(apex), r_ch, e_ch;
 	init_random_changeset(m_ch, 0, 1, 128, apex, false);
-	int ret = journal_insert(jj, m_ch, NULL);
+	int ret = journal_insert(jj, m_ch, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: store changeset (%s)", knot_strerror(ret));
 	ret = journal_sem_check(jj);
 	is_int(KNOT_EOK, ret, "journal: check after store changeset (%s)", knot_strerror(ret));
@@ -336,7 +336,7 @@ static void test_store_load(const knot_dname_t *apex)
 	for (; ret == KNOT_EOK && serial < 40000; ++serial) {
 		changeset_t *m_ch2 = changeset_new(apex);
 		init_random_changeset(m_ch2, serial, serial + 1, 128, apex, false);
-		ret = journal_insert(jj, m_ch2, NULL);
+		ret = journal_insert(jj, m_ch2, NULL, NULL);
 		if (ret != KNOT_EOK) {
 			changeset_free(m_ch2);
 			break;
@@ -378,7 +378,7 @@ static void test_store_load(const knot_dname_t *apex)
 	ret = changeset_init(&ch, apex);
 	ok(ret == KNOT_EOK, "journal: changeset init (%d)", ret);
 	init_random_changeset(&ch, serial, serial + 1, 555, apex, false);
-	ret = journal_insert(jj, &ch, NULL);
+	ret = journal_insert(jj, &ch, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: store after flush (%d)", ret);
 	ret = journal_sem_check(jj);
 	is_int(KNOT_EOK, ret, "journal check (%s)", knot_strerror(ret));
@@ -393,7 +393,7 @@ static void test_store_load(const knot_dname_t *apex)
 
 	changeset_init(&ch, apex);
 	init_random_changeset(&ch, 2, 3, 100, apex, false);
-	ret = journal_insert(jj, &ch, NULL);
+	ret = journal_insert(jj, &ch, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: insert discontinuous changeset (%s)", knot_strerror(ret));
 	ret = journal_sem_check(jj);
 	is_int(KNOT_EOK, ret, "journal check (%s)", knot_strerror(ret));
@@ -412,14 +412,14 @@ static void test_store_load(const knot_dname_t *apex)
 		changeset_clear(&ch);
 		changeset_init(&ch, apex);
 		init_random_changeset(&ch, serials[i], serials[i + 1], 100, apex, false);
-		ret = journal_insert(jj, &ch, NULL);
+		ret = journal_insert(jj, &ch, NULL, NULL);
 		is_int(i == 4 ? KNOT_EBUSY : KNOT_EOK, ret, "journal: inserting cycle (%s)", knot_strerror(ret));
 		ret = journal_sem_check(jj);
 		is_int(KNOT_EOK, ret, "journal check (%s)", knot_strerror(ret));
 	}
 	ret = journal_set_flushed(jj);
 	is_int(KNOT_EOK, ret, "journal: flush in cycle (%s)", knot_strerror(ret));
-	ret = journal_insert(jj, &ch, NULL);
+	ret = journal_insert(jj, &ch, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: inserted cycle (%s)", knot_strerror(ret));
 	ret = journal_sem_check(jj);
 	is_int(KNOT_EOK, ret, "journal check (%s)", knot_strerror(ret));
@@ -445,7 +445,7 @@ static void test_store_load(const knot_dname_t *apex)
 	is_int(KNOT_EOK, ret, "journal: insert zone-in-journal (%s)", knot_strerror(ret));
 	changeset_init(&r_ch, apex);
 	init_random_changeset(&r_ch, 1, 2, 200, apex, false);
-	ret = journal_insert(jj, &r_ch, NULL);
+	ret = journal_insert(jj, &r_ch, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: insert after zone-in-journal (%s)", knot_strerror(ret));
 	ret = load_j_list(&jj, true, 0, &read, &l);
 	is_int(KNOT_EOK, ret, "journal: load zone-in-journal (%s)", knot_strerror(ret));
@@ -470,7 +470,7 @@ static void test_size_control(const knot_dname_t *zone1, const knot_dname_t *zon
 	zone_journal_t jj2 = { &jdb, zone2, conf() };
 	changeset_t *small_ch2 = changeset_new(zone2);
 	init_random_changeset(small_ch2, 1, 2, 100, zone2, false);
-	int ret = journal_insert(jj2, small_ch2, NULL);
+	int ret = journal_insert(jj2, small_ch2, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: storing small changeset must be ok");
 
 	changeset_t *big_zij = changeset_new(zone1);
@@ -482,7 +482,7 @@ static void test_size_control(const knot_dname_t *zone1, const knot_dname_t *zon
 
 	changeset_t *big_ch2 = changeset_new(zone2);
 	init_random_changeset(big_ch2, 2, 3, 750, zone2, false);
-	ret = journal_insert(jj2, big_ch2, NULL);
+	ret = journal_insert(jj2, big_ch2, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: second zone is not affected by storing big zij of other zone");
 
 	journal_read_t *read = NULL;
@@ -490,7 +490,7 @@ static void test_size_control(const knot_dname_t *zone1, const knot_dname_t *zon
 	init_list(&l);
 	changeset_t *medium_ch1 = changeset_new(zone1);
 	init_random_changeset(medium_ch1, 1, 2, 300, zone1, false);
-	ret = journal_insert(jj, medium_ch1, NULL);
+	ret = journal_insert(jj, medium_ch1, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: storing medium changeset must be ok");
 	ret = load_j_list(&jj, true, 0, &read, &l);
 	is_int(KNOT_EOK, ret, "journal: load zone-in-journal (%s)", knot_strerror(ret));
@@ -500,7 +500,7 @@ static void test_size_control(const knot_dname_t *zone1, const knot_dname_t *zon
 
 	changeset_t *small_ch1 = changeset_new(zone1);
 	init_random_changeset(small_ch1, 2, 3, 100, zone1, false);
-	ret = journal_insert(jj, small_ch1, NULL);
+	ret = journal_insert(jj, small_ch1, NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: storing small changeset must be ok");
 	ret = load_j_list(&jj, true, 0, &read, &l);
 	is_int(KNOT_EOK, ret, "journal: load zone-in-journal (%s)", knot_strerror(ret));
@@ -510,7 +510,7 @@ static void test_size_control(const knot_dname_t *zone1, const knot_dname_t *zon
 
 	changeset_t *medium_ch1b = changeset_new(zone1);
 	init_random_changeset(medium_ch1b, 3, 4, 300, zone1, false);
-	ret = journal_insert(jj, medium_ch1b, NULL);
+	ret = journal_insert(jj, medium_ch1b, NULL, NULL);
 	is_int(KNOT_ESPACE, ret, "journal: not able to free space for changeset by merging");
 
 	changeset_t *too_big_zij = changeset_new(zone1);
@@ -726,7 +726,7 @@ static void test_merge(const knot_dname_t *apex)
 
 	// insert stuff and check the merge
 	for (i = 0; !merged_present() && i < 40000; i++) {
-		ret = journal_insert(jj, tm_chs(apex, i), NULL);
+		ret = journal_insert(jj, tm_chs(apex, i), NULL, NULL);
 		assert(ret == KNOT_EOK);
 	}
 	ret = journal_sem_check(jj);
@@ -743,7 +743,7 @@ static void test_merge(const knot_dname_t *apex)
 	journal_read_end(read);
 
 	// insert one more and check the #s of results
-	ret = journal_insert(jj, tm_chs(apex, i), NULL);
+	ret = journal_insert(jj, tm_chs(apex, i), NULL, NULL);
 	is_int(KNOT_EOK, ret, "journal: insert one more (%s)", knot_strerror(ret));
 	ret = load_j_list(&jj, false, 0, &read, &l);
 	is_int(KNOT_EOK, ret, "journal: journal_load_changesets2 must be ok");
@@ -764,7 +764,7 @@ static void test_merge(const knot_dname_t *apex)
 
 	// insert changeset that will cancel it mostly out
 	changeset_t *bigz_cancelout = tm2_chs_unzone(apex);
-	ret = journal_insert(jj, bigz_cancelout, NULL);
+	ret = journal_insert(jj, bigz_cancelout, NULL, NULL);
 	changeset_free(bigz_cancelout);
 	is_int(KNOT_EOK, ret, "journal: insert cancel-out changeset");
 
@@ -772,7 +772,7 @@ static void test_merge(const knot_dname_t *apex)
 	tm_chs(apex, -1);
 	while (changeset_to(tm_chs(apex, 0)) != 2) {  }
 	for (i = 0; i < 1600; i++) {
-		ret = journal_insert(jj, tm_chs(apex, i), NULL);
+		ret = journal_insert(jj, tm_chs(apex, i), NULL, NULL);
 		assert(ret == KNOT_EOK);
 	}
 
@@ -817,7 +817,7 @@ static void test_stress_base(const knot_dname_t *apex,
 		serial = 0;
 		while (true) {
 			changeset_set_soa_serials(&ch, serial, serial + 1, apex);
-			ret = journal_insert(jj, &ch, NULL);
+			ret = journal_insert(jj, &ch, NULL, NULL);
 			if (ret == KNOT_EOK) {
 				serial++;
 			} else {