diff --git a/Knot.files b/Knot.files
index 4003f6a4e738b303d36987b4a1374f252d2cb26e..5eb8dfb2f0107a67a79d2837e2a78bc31c483e14 100644
--- a/Knot.files
+++ b/Knot.files
@@ -265,6 +265,8 @@ src/knot/zone/adds_tree.c
 src/knot/zone/adds_tree.h
 src/knot/zone/adjust.c
 src/knot/zone/adjust.h
+src/knot/zone/catalog.c
+src/knot/zone/catalog.h
 src/knot/zone/contents.c
 src/knot/zone/contents.h
 src/knot/zone/measure.c
diff --git a/doc/configuration.rst b/doc/configuration.rst
index 1d58aebfbe55643dbbd42b0f3eab5ca750de6c3e..4a9cbfce143c3992f8cd3c7e10a5d5a633899202 100644
--- a/doc/configuration.rst
+++ b/doc/configuration.rst
@@ -577,6 +577,65 @@ master's SOA serial in a special variable inside KASP DB and appropriately
 modifiying AXFR/IXFR queries/answers to keep the communication with
 master consistent while applying the changes with a different serial.
 
+.. _catalog-zones:
+
+Catalog zones
+=============
+
+Catalog zone is a concept when the list of zones configured is maintained
+as contents of a special zone. This approach has the benefit of simple propagation
+of the actual zone list to slave servers. Especially when the list is frequently
+updated.
+
+Terminology first. *Catalog zone* is a meta-zone which shall not be a part
+of the DNS tree, but it contains information about the set of member zones and
+is transferable to slaves using common AXFR/IXFR techniques.
+*Catalog-member zone* (or just *member zone*) is a zone based on
+information from the catalog zone and not from configuration file/database.
+
+Catalog zone is handled almost in the same way as a regular zone.
+It can be configured using all the standard options (but for example
+DNSSEC signing would be useless), including master/slave configuration
+and ACLs. Being a catalog zone is indicated by setting the option
+:ref:`zone_catalog-template`. The difference is that standard DNS
+queries to a catalog zone are answered with REFUSED as if such a zone
+wouldn't exist, unless querying from an address with transfers enabled
+by ACL. The name of the catalog zone is arbitrary.
+It's possible to configure more catalog zones.
+
+.. WARNING::
+   Don't choose name for a catalog zone below a name of any other
+   existing zones configured on the server as it would effectively "shadow"
+   part of your DNS subtree.
+
+Upon catalog zone (re)load or change, all the PTR records in the zone
+are processed and member zones created, with zone names taken from the
+PTR records' RData, and zone settings taken from the confguration
+template specified by :ref:`zone_catalog-template`. Owner names of those PTR
+records may be arbitrary, but when a member zone is de-cataloged and
+re-cataloged again, the owner name of the relevant PTR record must
+be changed. It's also recommended that all the PTR records have different
+owner names (in other words, catalog zone RRSets consist of one RR each)
+to prevent oversized RRSets (not AXFR-able) and to achieve interoperability.
+
+All records other than PTR are ignored. However, they remain in the catalog
+zone and might be for example transfered to a slave, possibly interpreting
+catalog zones differently. SOA still needs to be present in the catalog zone
+and its serial handled appropriately. Apex NS record should be present
+for the sake of interoperability.
+
+Catalog zone may be modified using any standard means (e.g. AXFR/IXFR, DDNS,
+zone file reload). In the case of incremental change, only affected
+member zones are reloaded.
+
+Any de-cataloged member zone is purged immediately, including its
+zone file, journal, timers, and DNSSEC keys. The zone file is not
+deleted if :ref:`zone_zonefile-sync` is set to *-1* for member zones.
+
+When setting up catalog zones, it might be useful to set
+:ref:`database_catalog-db` and :ref:`database_catalog-db-max-size`
+to non-default values.
+
 .. _query-modules:
 
 Query modules
diff --git a/doc/man/knot.conf.5in b/doc/man/knot.conf.5in
index 01e916da2a5e14d9a84660d3c546ecd645b04ce3..610f7e5d2a963dee9dbfa0ab95498965bd090be2 100644
--- a/doc/man/knot.conf.5in
+++ b/doc/man/knot.conf.5in
@@ -667,6 +667,8 @@ database:
     kasp\-db\-max\-size: SIZE
     timer\-db: STR
     timer\-db\-max\-size: SIZE
+    catalog\-db: str
+    catalog\-db\-max\-size: SIZE
 .ft P
 .fi
 .UNINDENT
@@ -759,6 +761,26 @@ This value also influences server\(aqs usage of virtual memory.
 .UNINDENT
 .sp
 \fIDefault:\fP 100 MiB
+.SS catalog\-db
+.sp
+An explicit specification of the zone catalog database directory.
+Only useful if catalog\-zones are enabled.
+Non\-absolute path (i.e. not starting with \fB/\fP) is relative to
+\fI\%storage\fP\&.
+.sp
+\fIDefault:\fP \fI\%storage\fP/catalog
+.SS catalog\-db\-max\-size
+.sp
+The hard limit for the catalog database maximum size.
+.sp
+\fBNOTE:\fP
+.INDENT 0.0
+.INDENT 3.5
+This value also influences server\(aqs usage of virtual memory.
+.UNINDENT
+.UNINDENT
+.sp
+\fIDefault:\fP 20 GiB (512 MiB for 32\-bit)
 .SH KEYSTORE SECTION
 .sp
 DNSSEC keystore configuration.
@@ -1325,6 +1347,7 @@ zone:
     serial\-policy: increment | unixtime | dateserial
     refresh\-min\-interval: TIME
     refresh\-max\-interval: TIME
+    catalog\-template: template_id
     module: STR/STR ...
 .ft P
 .fi
@@ -1591,6 +1614,11 @@ Forced minimum zone refresh interval to avoid flooding master.
 Forced maximum zone refresh interval.
 .sp
 \fIDefault:\fP not set
+.SS catalog\-template
+.sp
+This zone is a catalog zone. For the catalog\-member zones, the specified configuration template will be applied.
+.sp
+\fIDefault:\fP not set
 .SS module
 .sp
 An ordered list of references to query modules in the form of \fImodule_name\fP or
diff --git a/doc/reference.rst b/doc/reference.rst
index 0148ca1364766a381e1b1cde24ea40ad3c158341..509686f1fd218e54f0d1d0f1fa1b37f78bc5e7fd 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -725,6 +725,8 @@ Configuration of databases for zone contents, DNSSEC metadata, or event timers.
      kasp-db-max-size: SIZE
      timer-db: STR
      timer-db-max-size: SIZE
+     catalog-db: str
+     catalog-db-max-size: SIZE
 
 .. _database_storage:
 
@@ -831,6 +833,30 @@ The hard limit for the timer database maximum size.
 
 *Default:* 100 MiB
 
+.. _database_catalog-db:
+
+catalog-db
+----------
+
+An explicit specification of the zone catalog database directory.
+Only useful if :ref:`catalog-zones` are enabled.
+Non-absolute path (i.e. not starting with ``/``) is relative to
+:ref:`storage<database_storage>`.
+
+*Default:* :ref:`storage<database_storage>`/catalog
+
+.. _database_catalog-db-max-size:
+
+catalog-db-max-size
+-------------------
+
+The hard limit for the catalog database maximum size.
+
+.. NOTE::
+   This value also influences server's usage of virtual memory.
+
+*Default:* 20 GiB (512 MiB for 32-bit)
+
 .. _Keystore section:
 
 Keystore section
@@ -1451,6 +1477,7 @@ Definition of zones served by the server.
      serial-policy: increment | unixtime | dateserial
      refresh-min-interval: TIME
      refresh-max-interval: TIME
+     catalog-template: template_id
      module: STR/STR ...
 
 .. _zone_domain:
@@ -1747,6 +1774,15 @@ Forced maximum zone refresh interval.
 
 *Default:* not set
 
+.. _zone_catalog-template:
+
+catalog-template
+----------------
+
+This zone is a catalog zone. For the catalog-member zones, the specified configuration template will be applied.
+
+*Default:* not set
+
 .. _zone_module:
 
 module
diff --git a/src/contrib/ucw/lists.c b/src/contrib/ucw/lists.c
index b3f254686f36a86e4cf13b253cdec5193644de91..84c6eaa839e946c85e6b9fbeed5491e3ecb6baec 100644
--- a/src/contrib/ucw/lists.c
+++ b/src/contrib/ucw/lists.c
@@ -2,7 +2,7 @@
  *	BIRD Library -- Linked Lists
  *
  *	(c) 1998 Martin Mares <mj@ucw.cz>
- *	(c) 2015, 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+ *	(c) 2015, 2020 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
  *
  *	Can be freely distributed and used under the terms of the GNU GPL.
  */
@@ -233,3 +233,12 @@ void ptrlist_deep_free(list_t *l, knot_mm_t *mm)
 	}
 	ptrlist_free(l, mm);
 }
+
+void ptrlist_free_custom(list_t *l, knot_mm_t *mm, ptrlist_free_cb free_cb)
+{
+	ptrnode_t *n;
+	WALK_LIST(n, *l) {
+		free_cb(n->d);
+	}
+	ptrlist_free(l, mm);
+}
diff --git a/src/contrib/ucw/lists.h b/src/contrib/ucw/lists.h
index 922e152f406e15fbfd453cf4e67b13d5bf66dc81..305806ad33e965bc06af192aadb759f61ad8e7f9 100644
--- a/src/contrib/ucw/lists.h
+++ b/src/contrib/ucw/lists.h
@@ -2,7 +2,7 @@
  *	BIRD Library -- Linked Lists
  *
  *	(c) 1998 Martin Mares <mj@ucw.cz>
- *	(c) 2015, 2017 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+ *	(c) 2015, 2020 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
  *
  *	Can be freely distributed and used under the terms of the GNU GPL.
  */
@@ -82,3 +82,5 @@ void ptrlist_free(list_t *, knot_mm_t *);
 void ptrlist_rem(ptrnode_t *node, knot_mm_t *mm);
 void ptrlist_deep_free(list_t *, knot_mm_t *);
 
+typedef void (*ptrlist_free_cb)(void *);
+void ptrlist_free_custom(list_t *l, knot_mm_t *mm, ptrlist_free_cb free_cb);
diff --git a/src/knot/Makefile.inc b/src/knot/Makefile.inc
index bd9341569f9e59858873974759f7b74366040af4..4cf49eed611ac33c051234268c89fb8b0f7f72ac 100644
--- a/src/knot/Makefile.inc
+++ b/src/knot/Makefile.inc
@@ -156,6 +156,8 @@ libknotd_la_SOURCES = \
 	knot/zone/adds_tree.h			\
 	knot/zone/adjust.c			\
 	knot/zone/adjust.h			\
+	knot/zone/catalog.c			\
+	knot/zone/catalog.h			\
 	knot/zone/contents.c			\
 	knot/zone/contents.h			\
 	knot/zone/measure.h			\
diff --git a/src/knot/conf/base.c b/src/knot/conf/base.c
index 98fdc454de2508c47e56c55cce912c75bfef2272..edb40c02cd26ae441058bd39bccac7f99fb4b37d 100644
--- a/src/knot/conf/base.c
+++ b/src/knot/conf/base.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2020 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
@@ -380,6 +380,8 @@ int conf_clone(
 		out->hostname = strdup(s_conf->hostname);
 	}
 
+	out->catalog = s_conf->catalog;
+
 	// Initialize cached values.
 	init_cache(out, false);
 
diff --git a/src/knot/conf/base.h b/src/knot/conf/base.h
index e33ba15d0f4513396d4a2c51ab45b967357fa8c4..475da16255eaf681e4deb61c881ccb1f471996ce 100644
--- a/src/knot/conf/base.h
+++ b/src/knot/conf/base.h
@@ -73,6 +73,8 @@ typedef struct {
 dynarray_declare(mod, module_t *, DYNARRAY_VISIBILITY_PUBLIC, 16)
 dynarray_declare(old_schema, yp_item_t *, DYNARRAY_VISIBILITY_PUBLIC, 16)
 
+struct knot_catalog;
+
 /*! Configuration context. */
 typedef struct {
 	/*! Cloned configuration indicator. */
@@ -131,6 +133,8 @@ typedef struct {
 	list_t *query_modules;
 	/*! Default query modules plan. */
 	struct query_plan *query_plan;
+	/*! Zone catalog database. */
+	struct knot_catalog *catalog;
 } conf_t;
 
 /*!
diff --git a/src/knot/conf/conf.c b/src/knot/conf/conf.c
index caf6fab67db612fef8e61e38a418df880064f57c..1ee35bae635be5ce8fe8dcc270ef1f18b86e3e7f 100644
--- a/src/knot/conf/conf.c
+++ b/src/knot/conf/conf.c
@@ -24,6 +24,7 @@
 #include "knot/conf/confdb.h"
 #include "knot/common/log.h"
 #include "knot/server/dthreads.h"
+#include "knot/zone/catalog.h"
 #include "libknot/libknot.h"
 #include "libknot/yparser/yptrafo.h"
 #include "contrib/macros.h"
@@ -179,6 +180,7 @@ conf_val_t conf_zone_get_txn(
 		CONF_LOG_ZONE(LOG_ERR, dname, "failed to read '%s/%s' (%s)",
 		              &C_ZONE[1], &key1_name[1], knot_strerror(val.code));
 		// FALLTHROUGH
+	case KNOT_YP_EINVAL_ID:
 	case KNOT_ENOENT:
 		break;
 	}
@@ -190,18 +192,41 @@ conf_val_t conf_zone_get_txn(
 		// Use the specified template.
 		conf_val(&val);
 		conf_db_get(conf, txn, C_TPL, key1_name, val.data, val.len, &val);
-		break;
+		goto got_template;
 	default:
 		CONF_LOG_ZONE(LOG_ERR, dname, "failed to read '%s/%s' (%s)",
 		              &C_ZONE[1], &C_TPL[1], knot_strerror(val.code));
 		// FALLTHROUGH
 	case KNOT_ENOENT:
 	case KNOT_YP_EINVAL_ID:
-		// Use the default template.
-		conf_db_get(conf, txn, C_TPL, key1_name, CONF_DEFAULT_ID + 1,
-		            CONF_DEFAULT_ID[0], &val);
+		break;
+	}
+
+	// Check if this is a catalog member zone.
+	if (conf->catalog != NULL) {
+		knot_dname_t *catalog = NULL;
+		int ret = knot_cat_get_catzone_thrsafe(conf->catalog, dname, &catalog);
+		if (ret == KNOT_EOK) {
+			conf_db_get(conf, txn, C_ZONE, C_CATALOG_TPL, catalog,
+			            knot_dname_size(catalog), &val);
+			if (val.code != KNOT_EOK) {
+				CONF_LOG_ZONE(LOG_ERR, catalog,
+				              "catalog zone has no catalog template (%s)",
+				              knot_strerror(val.code));
+				return val;
+			}
+			knot_dname_free(catalog, NULL);
+			conf_val(&val);
+			conf_db_get(conf, txn, C_TPL, key1_name, val.data, val.len, &val);
+			goto got_template;
+		}
 	}
 
+	// Use the default template.
+	conf_db_get(conf, txn, C_TPL, key1_name, CONF_DEFAULT_ID + 1,
+	            CONF_DEFAULT_ID[0], &val);
+
+got_template:
 	switch (val.code) {
 	default:
 		CONF_LOG_ZONE(LOG_ERR, dname, "failed to read '%s/%s' (%s)",
@@ -1079,6 +1104,15 @@ char* conf_zonefile_txn(
 	return get_filename(conf, txn, zone, file);
 }
 
+inline static bool legacy_db_fallback(
+	const yp_name_t *db_type)
+{
+	return (db_type[1] == C_JOURNAL_DB[1] ||
+	        db_type[1] == C_KASP_DB[1] ||
+	        db_type[1] == C_TIMER_DB[1]);
+	// warning comparing by first letter as every database has different!
+}
+
 char* conf_db_txn(
 	conf_t *conf,
 	knot_db_txn_t *txn,
@@ -1090,7 +1124,7 @@ char* conf_db_txn(
 	}
 
 	conf_val_t db_val = conf_get_txn(conf, txn, C_DB, db_type);
-	if (db_val.code != KNOT_EOK) {
+	if (db_val.code != KNOT_EOK && legacy_db_fallback(db_type)) {
 		db_val = conf_default_get_txn(conf, txn, db_type);
 	}
 
@@ -1108,7 +1142,7 @@ conf_val_t conf_db_param_txn(
 	const yp_name_t *legacy_param)
 {
 	conf_val_t val = conf_get_txn(conf, txn, C_DB, param);
-	if (val.code != KNOT_EOK) {
+	if (val.code != KNOT_EOK && legacy_param != NULL) {
 		val = conf_default_get_txn(conf, txn, legacy_param);
 	}
 
diff --git a/src/knot/conf/schema.c b/src/knot/conf/schema.c
index 82751293ef8a4276ad6d2f99d5cfe21356d0b7b7..64c7807301f46a59b02f548a780f43dae3f43912 100644
--- a/src/knot/conf/schema.c
+++ b/src/knot/conf/schema.c
@@ -230,6 +230,9 @@ static const yp_item_t desc_database[] = {
 	{ C_TIMER_DB,            YP_TSTR,  YP_VSTR = { "timers" } },
 	{ C_TIMER_DB_MAX_SIZE,   YP_TINT,  YP_VINT = { MEGA(1), VIRT_MEM_LIMIT(GIGA(100)),
 	                                               MEGA(100), YP_SSIZE } },
+	{ C_CATALOG_DB,          YP_TSTR,  YP_VSTR = { "catalog" } },
+	{ C_CATALOG_DB_MAX_SIZE, YP_TINT,  YP_VINT = { MEGA(5), VIRT_MEM_LIMIT(GIGA(100)),
+	                                               VIRT_MEM_LIMIT(GIGA(20)), YP_SSIZE } },
 	{ NULL }
 };
 
@@ -357,6 +360,7 @@ static const yp_item_t desc_policy[] = {
 	{ C_REFRESH_MAX_INTERVAL,YP_TINT,  YP_VINT = { 2, UINT32_MAX, UINT32_MAX, YP_STIME } }, \
 	{ C_REFRESH_MIN_INTERVAL,YP_TINT,  YP_VINT = { 2, UINT32_MAX, 2, YP_STIME } }, \
 	{ C_ADJUST_THR,          YP_TINT,  YP_VINT = { 1, UINT16_MAX, 1 } }, \
+	{ C_CATALOG_TPL,         YP_TREF,  YP_VREF = { C_TPL }, FLAGS, { check_ref } }, \
 	{ C_MODULE,              YP_TDATA, YP_VDATA = { 0, NULL, mod_id_to_bin, mod_id_to_txt }, \
 	                                   YP_FMULTI | FLAGS, { check_modref } }, \
 	{ C_COMMENT,             YP_TSTR,  YP_VNONE }, \
diff --git a/src/knot/conf/schema.h b/src/knot/conf/schema.h
index 2932401302e0a2893628ec9b5121084489b08771..426d479e7af21510f89f0c151e8d2c9fdc563540 100644
--- a/src/knot/conf/schema.h
+++ b/src/knot/conf/schema.h
@@ -31,6 +31,9 @@
 #define C_BACKEND		"\x07""backend"
 #define C_BG_WORKERS		"\x12""background-workers"
 #define C_BLOCK_NOTIFY_XFR	"\x1B""block-notify-after-transfer"
+#define C_CATALOG_DB            "\x0A""catalog-db"
+#define C_CATALOG_DB_MAX_SIZE   "\x13""catalog-db-max-size"
+#define C_CATALOG_TPL		"\x10""catalog-template"
 #define C_CDS_CDNSKEY		"\x13""cds-cdnskey-publish"
 #define C_CHK_INTERVAL		"\x0E""check-interval"
 #define C_COMMENT		"\x07""comment"
diff --git a/src/knot/journal/knot_lmdb.c b/src/knot/journal/knot_lmdb.c
index 7b247105093df997cdbc91a5c5019de71a89a04b..9d547a5866b74d5a61221e2e722c25ab6256c641 100644
--- a/src/knot/journal/knot_lmdb.c
+++ b/src/knot/journal/knot_lmdb.c
@@ -335,6 +335,17 @@ static bool curget(knot_lmdb_txn_t *txn, MDB_cursor_op op)
 	return (txn->ret == KNOT_EOK);
 }
 
+static int mdb_val_clone(const MDB_val *orig, MDB_val *clone)
+{
+	clone->mv_data = malloc(orig->mv_size);
+	if (clone->mv_data == NULL) {
+		return KNOT_ENOMEM;
+	}
+	clone->mv_size = orig->mv_size;
+	memcpy(clone->mv_data, orig->mv_data, clone->mv_size);
+	return KNOT_EOK;
+}
+
 bool knot_lmdb_find(knot_lmdb_txn_t *txn, MDB_val *what, knot_lmdb_find_t how)
 {
 	if (!txn_semcheck(txn) || !init_cursor(txn) || !txn_enomem(txn, what)) {
@@ -365,6 +376,28 @@ bool knot_lmdb_find(knot_lmdb_txn_t *txn, MDB_val *what, knot_lmdb_find_t how)
 	return succ;
 }
 
+// this is not bulletproof thread-safe (in case of LMDB fail-teardown, but mostly OK
+int knot_lmdb_find_threadsafe(knot_lmdb_txn_t *txn, MDB_val *key, MDB_val *val, knot_lmdb_find_t how)
+{
+	assert(how == KNOT_LMDB_EXACT);
+	if (key->mv_data == NULL) {
+		return KNOT_ENOMEM;
+	}
+	if (!txn->opened) {
+		return KNOT_EINVAL;
+	}
+	if (txn->ret != KNOT_EOK) {
+		return txn->ret;
+	}
+	MDB_val tmp = { 0 };
+	int ret = mdb_get(txn->txn, txn->db->dbi, key, &tmp);
+	err_to_knot(&ret);
+	if (ret == KNOT_EOK) {
+		ret = mdb_val_clone(&tmp, val);
+	}
+	return ret;
+}
+
 bool knot_lmdb_first(knot_lmdb_txn_t *txn)
 {
 	return txn_semcheck(txn) && init_cursor(txn) && curget(txn, MDB_FIRST);
diff --git a/src/knot/journal/knot_lmdb.h b/src/knot/journal/knot_lmdb.h
index f4934cff5afb9993ca52631d44b5c26009976eb1..80eaf45ecfd173d79f5f47547285d955ef112f4c 100644
--- a/src/knot/journal/knot_lmdb.h
+++ b/src/knot/journal/knot_lmdb.h
@@ -144,7 +144,7 @@ void knot_lmdb_deinit(knot_lmdb_db_t *db);
 /*!
  * \brief Return true if DB is open.
  */
-inline static bool knot_lmdb_is_open(knot_lmdb_db_t *db) { return db->env != NULL; }
+inline static bool knot_lmdb_is_open(knot_lmdb_db_t *db) { return db != NULL && db->env != NULL; }
 
 /*!
  * \brief Start a DB transaction.
@@ -186,6 +186,21 @@ void knot_lmdb_commit(knot_lmdb_txn_t *txn);
  */
 bool knot_lmdb_find(knot_lmdb_txn_t *txn, MDB_val *what, knot_lmdb_find_t how);
 
+/*!
+ * \brief Simple database lookup in case txn shared among threads.
+ *
+ * \param txn    DB transaction share among threads.
+ * \param key    Key to be searched for.
+ * \param val    Output: database value.
+ * \param how    Must be KNOT_LMDB_EXACT.
+ *
+ * \note Free val->mv_data afterwards!
+ *
+ * \retval KNOT_ENOENT   no such key in DB.
+ * \return KNOT_E*
+ */
+int knot_lmdb_find_threadsafe(knot_lmdb_txn_t *txn, MDB_val *key, MDB_val *val, knot_lmdb_find_t how);
+
 /*!
  * \brief Start iteration the whole DB from lexicographically first key.
  *
diff --git a/src/knot/nameserver/process_query.c b/src/knot/nameserver/process_query.c
index 32618ea882ddf3e1e04bcf7a66d36acd88d54183..2c52cf89b9b26b6c7dab55bd128167de76803919 100644
--- a/src/knot/nameserver/process_query.c
+++ b/src/knot/nameserver/process_query.c
@@ -423,6 +423,14 @@ static int prepare_answer(knot_pkt_t *query, knot_pkt_t *resp, knot_layer_t *ctx
 		qdata->extra->contents = qdata->extra->zone->contents;
 	}
 
+	if (query_type(query) == KNOTD_QUERY_TYPE_NORMAL &&
+	    qdata->extra->zone != NULL && (qdata->extra->zone->flags & ZONE_IS_CATALOG)) {
+		if (!process_query_acl_check(conf(), ACL_ACTION_TRANSFER, qdata)) {
+			qdata->extra->zone = NULL;
+			qdata->extra->contents = NULL;
+		}
+	}
+
 	return KNOT_EOK;
 }
 
diff --git a/src/knot/server/server.c b/src/knot/server/server.c
index 154b4354c92c1240d777cac7d95bdcec0c17d0fc..9131b3f3f7a3e0994fe9353897ad438e7258a5e9 100644
--- a/src/knot/server/server.c
+++ b/src/knot/server/server.c
@@ -520,6 +520,19 @@ int server_init(server_t *server, int bg_workers)
 		return KNOT_ENOMEM;
 	}
 
+	int ret = knot_cat_update_init(&server->catalog_upd);
+	if (ret != KNOT_EOK) {
+		worker_pool_destroy(server->workers);
+		evsched_deinit(&server->sched);
+		return ret;
+	}
+
+	char *catalog_dir = conf_db(conf(), C_CATALOG_DB);
+	conf_val_t catalog_size = conf_db_param(conf(), C_CATALOG_DB_MAX_SIZE, NULL);
+	knot_catalog_init(&server->catalog, catalog_dir, conf_int(&catalog_size));
+	free(catalog_dir);
+	conf()->catalog = &server->catalog;
+
 	char *journal_dir = conf_db(conf(), C_JOURNAL_DB);
 	conf_val_t journal_size = conf_db_param(conf(), C_JOURNAL_DB_MAX_SIZE, C_MAX_JOURNAL_DB_SIZE);
 	conf_val_t journal_mode = conf_db_param(conf(), C_JOURNAL_DB_MODE, C_JOURNAL_DB_MODE);
@@ -552,6 +565,10 @@ void server_deinit(server_t *server)
 		}
 	}
 
+	/* Free catalog zone context. */
+	knot_cat_update_deinit(&server->catalog_upd);
+	knot_catalog_deinit(&server->catalog);
+
 	/* Free remaining interfaces. */
 	server_deinit_iface_list(server->ifaces, server->n_ifaces);
 
diff --git a/src/knot/server/server.h b/src/knot/server/server.h
index b8e3c3e4bd2091c3e65e8c307ee443bebd112c31..bc7dc634d94c0910738eb842a66e6af479754bc2 100644
--- a/src/knot/server/server.h
+++ b/src/knot/server/server.h
@@ -22,6 +22,7 @@
 #include "knot/journal/knot_lmdb.h"
 #include "knot/server/dthreads.h"
 #include "knot/worker/pool.h"
+#include "knot/zone/catalog.h"
 #include "knot/zone/zonedb.h"
 
 struct server;
@@ -78,11 +79,11 @@ typedef struct server {
 	/*! \brief Server state tracking. */
 	volatile unsigned state;
 
-	/*! \brief Zone database. */
 	knot_zonedb_t *zone_db;
 	knot_lmdb_db_t timerdb;
 	knot_lmdb_db_t journaldb;
 	knot_lmdb_db_t kaspdb;
+	knot_catalog_t catalog;
 
 	/*! \brief I/O handlers. */
 	struct {
@@ -99,6 +100,9 @@ typedef struct server {
 	/*! \brief List of interfaces. */
 	iface_t *ifaces;
 	size_t n_ifaces;
+
+	/*! \brief Pending changes to catalog member zones. */
+	knot_cat_update_t catalog_upd;
 } server_t;
 
 /*!
diff --git a/src/knot/updates/zone-update.c b/src/knot/updates/zone-update.c
index b5d5fec54252b687a319f9b90bce5a2eeabf7bec..03ac4ef42b251199ab7bdbd3a69b74a9fd6b56a9 100644
--- a/src/knot/updates/zone-update.c
+++ b/src/knot/updates/zone-update.c
@@ -19,11 +19,14 @@
 #include "knot/updates/zone-update.h"
 #include "knot/zone/adds_tree.h"
 #include "knot/zone/adjust.h"
+#include "knot/zone/catalog.h"
 #include "knot/zone/serial.h"
 #include "knot/zone/zone-diff.h"
 #include "contrib/trim.h"
 #include "contrib/ucw/lists.h"
 
+#include <signal.h>
+#include <unistd.h>
 #include <urcu.h>
 
 // Call mem_trim() whenever accumuled size of updated zones reaches this size.
@@ -703,6 +706,35 @@ static int commit_full(conf_t *conf, zone_update_t *update)
 	return KNOT_EOK;
 }
 
+static int update_catalog(conf_t *conf, zone_update_t *update)
+{
+	conf_val_t val = conf_zone_get(conf, C_CATALOG_TPL, update->zone->name);
+	if (val.code != KNOT_EOK) {
+		return val.code == KNOT_ENOENT ? KNOT_EOK : val.code;
+	}
+
+	update->zone->flags |= ZONE_IS_CATALOG;
+
+	int ret = KNOT_EOK;
+	if ((update->flags & UPDATE_INCREMENTAL)) {
+		ret = knot_cat_update_from_zone(update->zone->catalog_upd, update->change.remove, true, update->zone->catalog);
+		if (ret == KNOT_EOK) {
+			ret = knot_cat_update_from_zone(update->zone->catalog_upd, update->change.add, false, NULL);
+		}
+	} else {
+		ret = knot_cat_update_del_all(update->zone->catalog_upd, update->zone->catalog, update->zone->name);
+		if (ret == KNOT_EOK) {
+			ret = knot_cat_update_from_zone(update->zone->catalog_upd, update->zone->contents, false, NULL);
+		}
+	}
+
+	if (ret == KNOT_EOK) {
+		kill(getpid(), SIGUSR1);
+	}
+
+	return ret;
+}
+
 typedef struct {
 	pthread_mutex_t lock;
 	size_t counter;
@@ -842,6 +874,11 @@ int zone_update_commit(conf_t *conf, zone_update_t *update)
 	zone_contents_t *old_contents;
 	old_contents = zone_switch_contents(update->zone, update->new_cont);
 
+	ret = commit_catalog(conf, update);
+	if (ret != KNOT_EOK) {
+		log_zone_warning(update->zone->name, "catalog zone not fully populated (%s)", knot_strerror(ret));
+	}
+
 	if (update->flags & (UPDATE_INCREMENTAL | UPDATE_HYBRID)) {
 		changeset_clear(&update->change);
 		changeset_clear(&update->extra_ch);
diff --git a/src/knot/zone/catalog.c b/src/knot/zone/catalog.c
new file mode 100644
index 0000000000000000000000000000000000000000..c33733b566c445ad910d236c50d6a87649f03d43
--- /dev/null
+++ b/src/knot/zone/catalog.c
@@ -0,0 +1,416 @@
+/*  Copyright (C) 2020 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
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "knot/zone/catalog.h"
+
+#include <assert.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "knot/common/log.h"
+#include "knot/conf/conf.h"
+#include "knot/zone/contents.h"
+
+#define CATALOG_VERSION "1.0"
+
+const MDB_val knot_catalog_iter_prefix = { 1, "" };
+
+void knot_catalog_init(knot_catalog_t *cat, const char *path, size_t mapsize)
+{
+	knot_lmdb_init(&cat->db, path, mapsize, 0, NULL);
+}
+
+int knot_catalog_open(knot_catalog_t *cat)
+{
+	if (!knot_lmdb_is_open(&cat->db)) {
+		int ret = knot_lmdb_open(&cat->db);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+	}
+	if (!cat->txn.opened) {
+		knot_lmdb_begin(&cat->db, &cat->txn, !(cat->db.env_flags & MDB_RDONLY));
+	}
+	if (cat->txn.ret == KNOT_EOK) {
+		MDB_val key = { 8, "\x01version" };
+		if (knot_lmdb_find(&cat->txn, &key, KNOT_LMDB_EXACT)) {
+			if (strncmp(CATALOG_VERSION, cat->txn.cur_val.mv_data,
+			            cat->txn.cur_val.mv_size) != 0) {
+				log_warning("unmatching catalog version");
+			}
+		} else if (!(cat->db.env_flags & MDB_RDONLY)) {
+			MDB_val val = { strlen(CATALOG_VERSION), CATALOG_VERSION };
+			knot_lmdb_insert(&cat->txn, &key, &val);
+		}
+	}
+	return cat->txn.ret;
+}
+
+int knot_catalog_deinit(knot_catalog_t *cat)
+{
+	if (cat->txn.opened) {
+		knot_lmdb_commit(&cat->txn);
+	}
+	knot_lmdb_deinit(&cat->db);
+	return cat->txn.ret;
+}
+
+static int bailiwick_shift(const knot_dname_t *subname, const knot_dname_t *name)
+{
+	const knot_dname_t *res = subname;
+	while (!knot_dname_is_equal(res, name)) {
+		if (*res == '\0') {
+			return -1;
+		}
+		res = knot_wire_next_label(res, NULL);
+	}
+	return res - subname;
+}
+
+int knot_catalog_add(knot_catalog_t *cat, const knot_dname_t *member,
+                     const knot_dname_t *owner, const knot_dname_t *catzone)
+{
+	int ret = knot_catalog_open(cat);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+	int bail = bailiwick_shift(owner, catzone);
+	if (bail < 0) {
+		return KNOT_EOUTOFZONE;
+	}
+	assert(bail >= 0 && bail < 256);
+	MDB_val key = knot_lmdb_make_key("BN", 0, member); // 0 for future purposes
+	MDB_val val = knot_lmdb_make_key("BBN", 0, bail, owner);
+
+	knot_lmdb_insert(&cat->txn, &key, &val);
+	free(key.mv_data);
+	free(val.mv_data);
+	return cat->txn.ret;
+}
+
+int knot_catalog_del(knot_catalog_t *cat, const knot_dname_t *member)
+{
+	MDB_val key = knot_lmdb_make_key("BN", 0, member);
+	knot_lmdb_del_prefix(&cat->txn, &key); // deletes one record
+	free(key.mv_data);
+	return cat->txn.ret;
+}
+
+void knot_catalog_curval(knot_catalog_t *cat, const knot_dname_t **member,
+                         const knot_dname_t **owner, const knot_dname_t **catzone)
+{
+	uint8_t zero, shift;
+	if (member != NULL) {
+		knot_lmdb_unmake_key(cat->txn.cur_key.mv_data, cat->txn.cur_key.mv_size,
+		                     "BN", &zero, member);
+	}
+	const knot_dname_t *ow;
+	knot_lmdb_unmake_curval(&cat->txn, "BBN", &zero, &shift, &ow);
+	if (owner != NULL) {
+		*owner = ow;
+	}
+	if (catzone != NULL) {
+		*catzone = ow + shift;
+	}
+}
+
+int knot_catalog_get_catzone(knot_catalog_t *cat, const knot_dname_t *member,
+                             const knot_dname_t **catzone)
+{
+	if (!knot_lmdb_is_open(&cat->db)) {
+		return KNOT_ENOENT;
+	}
+
+	MDB_val key = knot_lmdb_make_key("BN", 0, member);
+	if (knot_lmdb_find(&cat->txn, &key, KNOT_LMDB_EXACT)) {
+		knot_catalog_curval(cat, NULL, NULL, catzone);
+		free(key.mv_data);
+		return KNOT_EOK;
+	}
+	free(key.mv_data);
+	return MIN(cat->txn.ret, KNOT_ENOENT);
+}
+
+int knot_cat_get_catzone_thrsafe(knot_catalog_t *cat, const knot_dname_t *member,
+                                 knot_dname_t **catzone)
+{
+	if (!knot_lmdb_is_open(&cat->db)) {
+		return KNOT_ENOENT;
+	}
+
+	MDB_val key = knot_lmdb_make_key("BN", 0, member), val = { 0 };
+	int ret = knot_lmdb_find_threadsafe(&cat->txn, &key, &val, KNOT_LMDB_EXACT);
+	if (ret == KNOT_EOK) {
+		uint8_t zero, shift;
+		const knot_dname_t *ow = NULL;
+		knot_lmdb_unmake_key(val.mv_data, val.mv_size, "BBN", &zero, &shift, &ow);
+		*catzone = knot_dname_copy(ow + shift, NULL);
+		if (*catzone == NULL) {
+			ret = KNOT_ENOMEM;
+		}
+		free(val.mv_data);
+	}
+	free(key.mv_data);
+	return ret;
+}
+
+knot_cat_find_res_t knot_catalog_find(knot_catalog_t *cat, const knot_dname_t *member,
+                                      const knot_dname_t *owner, const knot_dname_t *catzone)
+{
+	MDB_val key = knot_lmdb_make_key("BN", 0, member);
+	int ret = MEMBER_NONE;
+	if (knot_lmdb_find(&cat->txn, &key, KNOT_LMDB_EXACT)) {
+		const knot_dname_t *ow, *cz;
+		knot_catalog_curval(cat, NULL, &ow, &cz);
+		if (!knot_dname_is_equal(cz, catzone)) {
+			ret = MEMBER_ZONE;
+		} else if (!knot_dname_is_equal(ow, owner)) {
+			ret = MEMBER_OWNER;
+		} else {
+			ret = MEMBER_EXACT;
+		}
+	}
+	if (cat->txn.ret != KNOT_EOK) {
+		ret = MEMBER_ERROR;
+	}
+	free(key.mv_data);
+	return ret;
+}
+
+int knot_cat_update_init(knot_cat_update_t *u)
+{
+	u->add = trie_create(NULL);
+	if (u->add == NULL) {
+		return KNOT_ENOMEM;
+	}
+	u->rem = trie_create(NULL);
+	if (u->rem == NULL) {
+		trie_free(u->add);
+		return KNOT_ENOMEM;
+	}
+	pthread_mutex_init(&u->mutex, 0);
+	return KNOT_EOK;
+}
+
+static int freecb(trie_val_t *tval, void *unused)
+{
+	(void)unused;
+	free(*(void **)tval);
+	return 0;
+}
+
+void knot_cat_update_clear(knot_cat_update_t *u)
+{
+	trie_apply(u->add, freecb, NULL);
+	trie_clear(u->add);
+	trie_apply(u->rem, freecb, NULL);
+	trie_clear(u->rem);
+}
+
+void knot_cat_update_deinit(knot_cat_update_t *u)
+{
+	pthread_mutex_destroy(&u->mutex);
+	trie_free(u->add);
+	trie_free(u->rem);
+}
+
+int knot_cat_update_add(knot_cat_update_t *u, const knot_dname_t *member,
+                        const knot_dname_t *owner, const knot_dname_t *catzone,
+                        bool remove)
+{
+	int bail = bailiwick_shift(owner, catzone);
+	if (bail < 0) {
+		return KNOT_EOUTOFZONE;
+	}
+	assert(bail >= 0 && bail < 256);
+
+	knot_dname_storage_t lf_storage;
+	uint8_t *lf = knot_dname_lf(member, lf_storage);
+
+	trie_t *toadd = remove ? u->rem : u->add;
+	trie_t *check = remove ? u->add : u->rem;
+
+	bool just_reconf = false;
+
+	trie_val_t *found = trie_get_try(check, lf + 1, lf[0]);
+	if (found != NULL) {
+		knot_cat_upd_val_t *counter = *found;
+		assert(knot_dname_is_equal(counter->member, member));
+		if (knot_dname_is_equal(counter->owner, owner)) {
+			assert(knot_dname_is_equal(counter->catzone, catzone));
+			trie_del(check, lf + 1, lf[0], NULL);
+			free(counter);
+			return KNOT_EOK;
+		} else {
+			counter->just_reconf = true;
+			just_reconf = true;
+		}
+	}
+
+	size_t member_size = knot_dname_size(member);
+	size_t owner_size = knot_dname_size(owner);
+
+	knot_cat_upd_val_t *val = malloc(sizeof(*val) + member_size + owner_size);
+	if (val == NULL) {
+		return KNOT_ENOMEM;
+	}
+	trie_val_t *added = trie_get_ins(toadd, lf + 1, lf[0]);
+	if (added == NULL) {
+		free(val);
+		return KNOT_ENOMEM;
+	}
+	if (*added != NULL) { // rewriting existing val
+		free(*added);
+	}
+	val->member = (knot_dname_t *)(val + 1);
+	val->owner = val->member + member_size;
+	val->catzone = val->owner + bail;
+	memcpy(val->member, member, member_size);
+	memcpy(val->owner, owner, owner_size);
+	val->just_reconf = just_reconf;
+	*added = val;
+	return KNOT_EOK;
+}
+
+knot_cat_upd_val_t *knot_cat_update_get(knot_cat_update_t *u, const knot_dname_t *member, bool remove)
+{
+	knot_dname_storage_t lf_storage;
+	uint8_t *lf = knot_dname_lf(member, lf_storage);
+
+	trie_val_t *found = trie_get_try(remove ? u->rem : u->add, lf + 1, lf[0]);
+	return found == NULL ? NULL : *(knot_cat_upd_val_t **)found;
+}
+
+typedef struct {
+	knot_cat_update_t *u;
+	const knot_dname_t *apex;
+	bool remove;
+	knot_catalog_t *check;
+} cat_upd_ctx_t;
+
+static int cat_update_add_node(zone_node_t *node, void *data)
+{
+	cat_upd_ctx_t *ctx = data;
+	const knot_rdataset_t *ptr = node_rdataset(node, KNOT_RRTYPE_PTR);
+	if (ptr == NULL || ptr->count == 0) {
+		return KNOT_EOK;
+	}
+	knot_rdata_t *rdata = ptr->rdata;
+	int ret = KNOT_EOK;
+	for (int i = 0; ret == KNOT_EOK && i < ptr->count; i++) {
+		const knot_dname_t *member = knot_ptr_name(rdata);
+		if (ctx->check != NULL && ctx->remove &&
+		    knot_catalog_find(ctx->check, member, node->owner, ctx->apex) != MEMBER_EXACT) {
+			rdata = knot_rdataset_next(rdata);
+			continue;
+		}
+		ret = knot_cat_update_add(ctx->u, member, node->owner, ctx->apex, ctx->remove);
+		rdata = knot_rdataset_next(rdata);
+	}
+	return ret;
+}
+
+int knot_cat_update_from_zone(knot_cat_update_t *u, struct zone_contents *zone,
+                              bool remove, knot_catalog_t *check)
+{
+	cat_upd_ctx_t ctx = { u, zone->apex->owner, remove, check };
+	pthread_mutex_lock(&u->mutex);
+	int ret = zone_contents_apply(zone, cat_update_add_node, &ctx);
+	pthread_mutex_unlock(&u->mutex);
+	return ret;
+}
+
+int knot_cat_update_del_all(knot_cat_update_t *u, knot_catalog_t *cat, const knot_dname_t *zone)
+{
+	int ret = knot_catalog_open(cat);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	pthread_mutex_lock(&u->mutex);
+	knot_catalog_foreach(cat) { // TODO possible speedup by indexing which member zones belong to a catalog zone
+		const knot_dname_t *mem, *ow, *cz;
+		knot_catalog_curval(cat, &mem, &ow, &cz);
+		if (knot_dname_is_equal(cz, zone)) {
+			ret = knot_cat_update_add(u, mem, ow, cz, true);
+			if (ret != KNOT_EOK) {
+				pthread_mutex_unlock(&u->mutex);
+				return ret;
+			}
+		}
+	}
+	pthread_mutex_unlock(&u->mutex);
+	return cat->txn.ret;
+}
+
+static void print_dname(const knot_dname_t *d)
+{
+	char tmp[KNOT_DNAME_TXT_MAXLEN];
+	knot_dname_to_str(tmp, d, sizeof(tmp));
+	printf("%s ", tmp);
+}
+
+static void print_dname3(const char *pre, const knot_dname_t *a, const knot_dname_t *b, const knot_dname_t *c, const char *suff)
+{
+	printf("%s ", pre);
+	print_dname(a);
+	print_dname(b);
+	print_dname(c);
+	printf("%s\n", suff);
+}
+
+void knot_cat_update_print(const char *intro, knot_catalog_t *cat, knot_cat_update_t *u)
+{
+	ssize_t cattot = 0, uplus = 0, uminus = 0;
+
+	printf("Catalog (%s)\n", intro);
+
+	if (cat != NULL) {
+		int ret = knot_catalog_open(cat);
+		if (ret != KNOT_EOK) {
+			printf("Catalog print failed (%s)\n", knot_strerror(ret));
+			return;
+		}
+
+		knot_catalog_foreach(cat) {
+			const knot_dname_t *mem, *ow, *cz;
+			knot_catalog_curval(cat, &mem, &ow, &cz);
+			print_dname3("*", mem, ow, cz, "");
+			cattot++;
+		}
+	}
+	if (u != NULL) {
+		knot_cat_it_t *it = knot_cat_it_begin(u, true);
+		while (!knot_cat_it_finised(it)) {
+			knot_cat_upd_val_t *val = knot_cat_it_val(it);
+			print_dname3("-", val->member, val->owner, val->catzone, "");
+			uminus++;
+			knot_cat_it_next(it);
+		}
+		knot_cat_it_free(it);
+
+		it = knot_cat_it_begin(u, false);
+		while (!knot_cat_it_finised(it)) {
+			knot_cat_upd_val_t *val = knot_cat_it_val(it);
+			print_dname3("+", val->member, val->owner, val->catzone, val->just_reconf ? "JR" : "");
+			uplus++;
+			knot_cat_it_next(it);
+		}
+		knot_cat_it_free(it);
+	}
+	printf("Catalog: *%zd -%zd +%zd\n", cattot, uminus, uplus);
+}
diff --git a/src/knot/zone/catalog.h b/src/knot/zone/catalog.h
new file mode 100644
index 0000000000000000000000000000000000000000..54d2af63cbed3c8efcab58ccef6a033de943d7cd
--- /dev/null
+++ b/src/knot/zone/catalog.h
@@ -0,0 +1,128 @@
+/*  Copyright (C) 2020 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
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <pthread.h>
+
+#include "libknot/libknot.h"
+#include "contrib/qp-trie/trie.h"
+#include "knot/journal/knot_lmdb.h"
+
+typedef struct knot_catalog {
+	knot_lmdb_db_t db;
+	knot_lmdb_txn_t txn; // RW transaction open all the time
+} knot_catalog_t;
+
+typedef enum {
+	MEMBER_NONE,   // this member zone is not in any catalog
+	MEMBER_EXACT,  // this member zone precisely matches lookup
+	MEMBER_ZONE,   // this member zone is in different catalog
+	MEMBER_OWNER,  // this member zone is in same catalog with diferent owner
+	MEMBER_ERROR,  // find error code in cat->txn.ret
+} knot_cat_find_res_t;
+
+typedef struct {
+	trie_t *rem;
+	trie_t *add;
+	pthread_mutex_t mutex;
+} knot_cat_update_t;
+
+typedef struct {
+	knot_dname_t *member;
+	knot_dname_t *owner;
+	knot_dname_t *catzone;
+	bool just_reconf;
+} knot_cat_upd_val_t;
+
+extern const MDB_val knot_catalog_iter_prefix;
+
+void knot_catalog_init(knot_catalog_t *cat, const char *path, size_t mapsize);
+
+int knot_catalog_open(knot_catalog_t *cat);
+
+int knot_catalog_deinit(knot_catalog_t *cat);
+
+int knot_catalog_add(knot_catalog_t *cat, const knot_dname_t *member,
+                     const knot_dname_t *owner, const knot_dname_t *catzone);
+
+inline static int knot_catalog_add2(knot_catalog_t *cat, const knot_cat_upd_val_t *val)
+{
+	return knot_catalog_add(cat, val->member, val->owner, val->catzone);
+}
+
+int knot_catalog_del(knot_catalog_t *cat, const knot_dname_t *member);
+
+inline static int knot_catalog_del2(knot_catalog_t *cat, const knot_cat_upd_val_t *val)
+{
+	assert(!val->just_reconf); // just re-add in this case
+	return knot_catalog_del(cat, val->member);
+}
+
+#define knot_catalog_foreach(cat) knot_lmdb_foreach(&(cat)->txn, (MDB_val *)&knot_catalog_iter_prefix)
+
+void knot_catalog_curval(knot_catalog_t *cat, const knot_dname_t **member,
+                         const knot_dname_t **owner, const knot_dname_t **catzone);
+
+int knot_catalog_get_catzone(knot_catalog_t *cat, const knot_dname_t *member,
+                             const knot_dname_t **catzone);
+
+int knot_cat_get_catzone_thrsafe(knot_catalog_t *cat, const knot_dname_t *member,
+                                 knot_dname_t **catzone);
+
+knot_cat_find_res_t knot_catalog_find(knot_catalog_t *cat, const knot_dname_t *member,
+                                      const knot_dname_t *owner, const knot_dname_t *catzone);
+
+int knot_cat_update_init(knot_cat_update_t *u);
+
+void knot_cat_update_clear(knot_cat_update_t *u);
+
+void knot_cat_update_deinit(knot_cat_update_t *u);
+
+int knot_cat_update_add(knot_cat_update_t *u, const knot_dname_t *member,
+                        const knot_dname_t *owner, const knot_dname_t *catzone,
+                        bool remove);
+
+knot_cat_upd_val_t *knot_cat_update_get(knot_cat_update_t *u, const knot_dname_t *member, bool remove);
+
+struct zone_contents;
+
+int knot_cat_update_from_zone(knot_cat_update_t *u, struct zone_contents *zone,
+                              bool remove, knot_catalog_t *check);
+
+int knot_cat_update_del_all(knot_cat_update_t *u, knot_catalog_t *cat, const knot_dname_t *zone);
+
+typedef trie_it_t knot_cat_it_t;
+
+inline static knot_cat_it_t *knot_cat_it_begin(knot_cat_update_t *u, bool remove)
+{
+	return trie_it_begin(remove ? u->rem : u->add);
+}
+
+inline static knot_cat_upd_val_t *knot_cat_it_val(knot_cat_it_t *it)
+{
+	return *(knot_cat_upd_val_t **)trie_it_val(it);
+}
+
+inline static bool knot_cat_it_finised(knot_cat_it_t *it)
+{
+	return it == NULL || trie_it_finished(it);
+}
+
+#define knot_cat_it_next trie_it_next
+#define knot_cat_it_free trie_it_free
+
+void knot_cat_update_print(const char *intro, knot_catalog_t *cat, knot_cat_update_t *u);
diff --git a/src/knot/zone/zone.h b/src/knot/zone/zone.h
index bed37ec054fab7b92b3558a01e9d3788170faf50..3af58fa38b1c0485e9c1a75b0c36a2ebf09761ca 100644
--- a/src/knot/zone/zone.h
+++ b/src/knot/zone/zone.h
@@ -22,6 +22,7 @@
 #include "knot/journal/journal_basic.h"
 #include "knot/events/events.h"
 #include "knot/updates/changesets.h"
+#include "knot/zone/catalog.h"
 #include "knot/zone/contents.h"
 #include "knot/zone/timers.h"
 #include "libknot/dname.h"
@@ -38,6 +39,8 @@ typedef enum zone_flag_t {
 	ZONE_FORCE_FLUSH    = 1 << 2, /*!< Force zone flush. */
 	ZONE_FORCE_KSK_ROLL = 1 << 3, /*!< Force KSK/CSK rollover. */
 	ZONE_FORCE_ZSK_ROLL = 1 << 4, /*!< Force ZSK rollover. */
+	ZONE_IS_CATALOG     = 1 << 5, /*!< This is a catalog. */
+	ZONE_IS_CAT_MEMBER  = 1 << 6, /*!< This zone exists according to a catalog. */
 } zone_flag_t;
 
 /*!
@@ -82,6 +85,10 @@ typedef struct zone
 	/*! \brief Ptr to journal DB (in struct server) */
 	knot_lmdb_db_t *kaspdb;
 
+	/*! \brief Ptr to catalog and ist changeset changes (in struct server) */
+	knot_catalog_t *catalog;
+	knot_cat_update_t *catalog_upd;
+
 	/*! \brief Preferred master lock. */
 	pthread_mutex_t preferred_lock;
 	/*! \brief Preferred master for remote operation. */
diff --git a/src/knot/zone/zonedb-load.c b/src/knot/zone/zonedb-load.c
index e9922ae0f343e16f2784be708e0072cb06f6ac90..9ffb147a699dc16f4577f9b4a6202b561cb58fdd 100644
--- a/src/knot/zone/zonedb-load.c
+++ b/src/knot/zone/zonedb-load.c
@@ -15,11 +15,14 @@
  */
 
 #include <assert.h>
+#include <unistd.h>
 #include <urcu.h>
 
 #include "knot/common/log.h"
 #include "knot/conf/module.h"
 #include "knot/events/replan.h"
+#include "knot/journal/journal_metadata.h"
+#include "knot/zone/catalog.h"
 #include "knot/zone/timers.h"
 #include "knot/zone/zone-load.h"
 #include "knot/zone/zone.h"
@@ -34,15 +37,22 @@ static bool zone_file_updated(conf_t *conf, const zone_t *old_zone,
 	assert(conf);
 	assert(zone_name);
 
+	if (old_zone == NULL) {
+		return false;
+	}
+
 	char *zonefile = conf_zonefile(conf, zone_name);
 	struct timespec mtime;
 	int ret = zonefile_exists(zonefile, &mtime);
 	free(zonefile);
 
-	return (ret == KNOT_EOK && old_zone != NULL &&
-	        !(old_zone->zonefile.exists &&
-		  old_zone->zonefile.mtime.tv_sec == mtime.tv_sec &&
-		  old_zone->zonefile.mtime.tv_nsec == mtime.tv_nsec));
+	if (ret == KNOT_EOK) {
+		return !(old_zone->zonefile.exists &&
+		         old_zone->zonefile.mtime.tv_sec == mtime.tv_sec &&
+		         old_zone->zonefile.mtime.tv_nsec == mtime.tv_nsec);
+	} else {
+		return old_zone->zonefile.exists;
+	}
 }
 
 static zone_t *create_zone_from(const knot_dname_t *name, server_t *server)
@@ -54,6 +64,8 @@ static zone_t *create_zone_from(const knot_dname_t *name, server_t *server)
 
 	zone->journaldb = &server->journaldb;
 	zone->kaspdb = &server->kaspdb;
+	zone->catalog = &server->catalog;
+	zone->catalog_upd = &server->catalog_upd;
 
 	int result = zone_events_setup(zone, server->workers, &server->sched);
 	if (result != KNOT_EOK) {
@@ -115,6 +127,7 @@ static zone_t *create_zone_reload(conf_t *conf, const knot_dname_t *name,
 	}
 
 	zone->contents = old_zone->contents;
+	zone->flags = (old_zone->flags & (ZONE_IS_CATALOG | ZONE_IS_CAT_MEMBER));
 
 	zone->timers = old_zone->timers;
 	timers_sanitize(conf, zone);
@@ -209,18 +222,134 @@ static void mark_changed_zones(knot_zonedb_t *zonedb, trie_t *changed)
 	trie_it_free(it);
 }
 
+static void zone_purge(conf_t *conf, zone_t *zone, server_t *server)
+{
+	(void)zone_timers_sweep(&server->timerdb, (sweep_cb)knot_dname_cmp, zone->name);
+
+	conf_val_t sync = conf_zone_get(conf, C_ZONEFILE_SYNC, zone->name);
+	if (conf_int(&sync) > -1) {
+		char *zonefile = conf_zonefile(conf, zone->name);
+		(void)unlink(zonefile);
+		free(zonefile);
+	}
+
+	(void)journal_scrape_with_md(zone_journal(zone), true);
+	if (knot_lmdb_open(zone->kaspdb) == KNOT_EOK) {
+		(void)kasp_db_delete_all(zone->kaspdb, zone->name);
+	}
+}
+
+static zone_contents_t *zone_expire(zone_t *zone)
+{
+	zone->timers.next_refresh = time(NULL);
+	return zone_switch_contents(zone, NULL);
+}
+
+static bool check_open_catalog(knot_catalog_t *cat) {
+	if (knot_lmdb_exists(&cat->db)) {
+		int ret = knot_catalog_open(cat);
+		if (ret != KNOT_EOK) {
+			log_error("failed to open existing zone catalog");
+		} else {
+			return true;
+		}
+	}
+	return false;
+}
+
+static zone_t *reuse_member_zone(zone_t *zone, server_t *server, conf_t *conf,
+                                 list_t *expired_contents)
+{
+	if (!(zone->flags & ZONE_IS_CAT_MEMBER)) {
+		return NULL;
+	}
+
+	knot_cat_upd_val_t *upd = knot_cat_update_get(&server->catalog_upd, zone->name, true);
+	if (upd != NULL) {
+		if (upd->just_reconf) {
+			zone_purge(conf, zone, server);
+			knot_sem_wait(&zone->cow_lock);
+			ptrlist_add(expired_contents, zone_expire(zone), NULL);
+			knot_sem_post(&zone->cow_lock);
+		} else {
+			return NULL; // zone to be removed
+		}
+	}
+
+	zone_t *newzone = create_zone(conf, zone->name, server, zone);
+	if (newzone == NULL) {
+		log_zone_error(zone->name, "zone cannot be created");
+	} else {
+		assert(newzone->flags & ZONE_IS_CAT_MEMBER);
+		conf_activate_modules(conf, server, newzone->name, &newzone->query_modules,
+		                      &newzone->query_plan);
+	}
+	return newzone;
+}
+
+// cold start of knot: add unchanged member zone to zonedb
+static zone_t *reuse_cold_zone(const knot_dname_t *zname, server_t *server, conf_t *conf)
+{
+	knot_cat_upd_val_t *upd = knot_cat_update_get(&server->catalog_upd, zname, true);
+	if (upd != NULL && !upd->just_reconf) {
+		return NULL; // zone will be removed immediately
+	}
+
+	zone_t *zone = create_zone(conf, zname, server, NULL);
+	if (zone == NULL) {
+		log_zone_error(zname, "zone cannot be created");
+	} else {
+		zone->flags |= ZONE_IS_CAT_MEMBER;
+		conf_activate_modules(conf, server, zone->name, &zone->query_modules,
+		                      &zone->query_plan);
+	}
+	return zone;
+}
+
+static zone_t *add_member_zone(knot_cat_upd_val_t *val, knot_zonedb_t *check, server_t *server, conf_t *conf)
+{
+	if (val->just_reconf) {
+		return NULL;
+	}
+
+	if (knot_zonedb_find(check, val->member) != NULL) {
+		log_zone_warning(val->member, "zone already configured, skipping creation");
+		return NULL;
+	}
+
+	int ret = knot_catalog_add2(&server->catalog, val);
+	if (ret != KNOT_EOK) {
+		log_zone_error(val->member, "failed adding member zone to catalog (%s)",
+		               knot_strerror(ret));
+		return NULL;
+	}
+
+	zone_t *zone = create_zone(conf, val->member, server, NULL);
+	if (zone == NULL) {
+		log_zone_error(val->member, "zone cannot be created");
+		knot_catalog_del2(conf->catalog, val);
+	} else {
+		zone->flags |= ZONE_IS_CAT_MEMBER;
+		conf_activate_modules(conf, server, zone->name, &zone->query_modules,
+		                      &zone->query_plan);
+		log_zone_info(val->member, "zone added from catalog");
+	}
+	return zone;
+}
+
 /*!
  * \brief Create new zone database.
  *
  * Zones that should be retained are just added from the old database to the
  * new. New zones are loaded.
  *
- * \param conf    New server configuration.
- * \param server  Server instance.
+ * \param conf              New server configuration.
+ * \param server            Server instance.
+ * \param expired_contents  Out: ptrlist of zone_contents_t to be deep freed after sync RCU.
  *
  * \return New zone database.
  */
-static knot_zonedb_t *create_zonedb(conf_t *conf, server_t *server)
+static knot_zonedb_t *create_zonedb(conf_t *conf, server_t *server, list_t *expired_contents)
 {
 	assert(conf);
 	assert(server);
@@ -265,6 +394,38 @@ static knot_zonedb_t *create_zonedb(conf_t *conf, server_t *server)
 		knot_zonedb_insert(db_new, zone);
 	}
 
+	if (db_old != NULL) {
+		knot_zonedb_iter_t *it = knot_zonedb_iter_begin(db_old);
+		while (!knot_zonedb_iter_finished(it)) {
+			zone_t *newzone = reuse_member_zone(knot_zonedb_iter_val(it),
+			                                    server, conf, expired_contents);
+			if (newzone != NULL) {
+				knot_zonedb_insert(db_new, newzone);
+			}
+			knot_zonedb_iter_next(it);
+		}
+		knot_zonedb_iter_free(it);
+	} else if (check_open_catalog(&server->catalog)) {
+		knot_catalog_foreach(&server->catalog) {
+			const knot_dname_t *member = NULL;
+			knot_catalog_curval(&server->catalog, &member, NULL, NULL);
+			zone_t *zone = reuse_cold_zone(member, server, conf);
+			if (zone != NULL) {
+				knot_zonedb_insert(db_new, zone);
+			}
+		}
+	}
+
+	knot_cat_it_t *it = knot_cat_it_begin(&server->catalog_upd, false);
+	while (!knot_cat_it_finised(it)) {
+		zone_t *zone = add_member_zone(knot_cat_it_val(it), db_new, server, conf);
+		if (zone != NULL) {
+			knot_zonedb_insert(db_new, zone);
+		}
+		knot_cat_it_next(it);
+	}
+	knot_cat_it_free(it);
+
 	return db_new;
 }
 
@@ -276,23 +437,23 @@ static knot_zonedb_t *create_zonedb(conf_t *conf, server_t *server)
  *
  * \param conf    New server configuration.
  * \param db_old  Old zone database to remove.
- * \param db_new  New zone database for comparison if full reload.
+ * \param server  Server context.
   */
 static void remove_old_zonedb(conf_t *conf, knot_zonedb_t *db_old,
-                              knot_zonedb_t *db_new)
+                              server_t *server)
 {
-	if (db_old == NULL) {
-		return;
-	}
+	knot_zonedb_t *db_new = server->zone_db;
 
 	bool full = !(conf->io.flags & CONF_IO_FACTIVE) ||
 	            (conf->io.flags & CONF_IO_FRLD_ZONES);
 
-	knot_zonedb_iter_t *it = knot_zonedb_iter_begin(db_old);
+	if (db_old == NULL) {
+		goto catalog_only;
+	}
 
+	knot_zonedb_iter_t *it = knot_zonedb_iter_begin(db_old);
 	while (!knot_zonedb_iter_finished(it)) {
 		zone_t *zone = knot_zonedb_iter_val(it);
-
 		if (full) {
 			/* Check if reloaded (reused contents). */
 			if (knot_zonedb_find(db_new, zone->name)) {
@@ -316,6 +477,25 @@ static void remove_old_zonedb(conf_t *conf, knot_zonedb_t *db_old,
 
 	knot_zonedb_iter_free(it);
 
+catalog_only:
+	; /* Remove deleted cataloged zones from conf. */
+	knot_cat_it_t *tit = knot_cat_it_begin(&server->catalog_upd, true);
+	while (!knot_cat_it_finised(tit)) {
+		knot_cat_upd_val_t *val = knot_cat_it_val(tit);
+		if (!val->just_reconf) {
+			knot_catalog_del(&server->catalog, val->member);
+			zone_t *zone = knot_zonedb_find(db_old, val->member);
+			if (zone != NULL) {
+				zone_purge(conf, zone, server);
+			}
+		}
+		knot_cat_it_next(tit);
+	}
+	knot_cat_it_free(tit);
+
+	/* Clear catalog changes. No need to use mutex as this is done from main thread while all zone events are paused. */
+	knot_cat_update_clear(&server->catalog_upd);
+
 	if (full) {
 		knot_zonedb_deep_free(&db_old, false);
 	} else {
@@ -329,8 +509,11 @@ void zonedb_reload(conf_t *conf, server_t *server)
 		return;
 	}
 
+	list_t contents_tofree;
+	init_list(&contents_tofree);
+
 	/* Insert all required zones to the new zone DB. */
-	knot_zonedb_t *db_new = create_zonedb(conf, server);
+	knot_zonedb_t *db_new = create_zonedb(conf, server, &contents_tofree);
 	if (db_new == NULL) {
 		log_error("failed to create new zone database");
 		return;
@@ -343,6 +526,8 @@ void zonedb_reload(conf_t *conf, server_t *server)
 	/* Wait for readers to finish reading old zone database. */
 	synchronize_rcu();
 
+	ptrlist_free_custom(&contents_tofree, NULL, (ptrlist_free_cb)zone_contents_deep_free);
+
 	/* Remove old zone DB. */
-	remove_old_zonedb(conf, db_old, db_new);
+	remove_old_zonedb(conf, db_old, server);
 }
diff --git a/src/knot/zone/zonedb.c b/src/knot/zone/zonedb.c
index 9d28fd0be33b60c610505a63f1a55172ac97a06b..af8050510d03ba99ad87b202d625667b514c937a 100644
--- a/src/knot/zone/zonedb.c
+++ b/src/knot/zone/zonedb.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2019 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2020 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
@@ -26,8 +26,11 @@
 /*! \brief Discard zone in zone database. */
 static void discard_zone(zone_t *zone, bool abort_txn)
 {
+	const knot_dname_t *unused = NULL;
+
 	// Don't flush if removed zone (no previous configuration available).
-	if (conf_rawid_exists(conf(), C_ZONE, zone->name, knot_dname_size(zone->name))) {
+	if (conf_rawid_exists(conf(), C_ZONE, zone->name, knot_dname_size(zone->name)) ||
+	    knot_catalog_get_catzone(conf()->catalog, zone->name, &unused) == KNOT_EOK) {
 		uint32_t journal_serial, zone_serial = zone_contents_serial(zone->contents);
 		bool exists;
 
diff --git a/src/libknot/rrtype/rdname.h b/src/libknot/rrtype/rdname.h
index 6f4c09c9ab67e0729b994d69695682dea21e3096..e2b10c47fabce215df804cfb62149e0a54cb3e0c 100644
--- a/src/libknot/rrtype/rdname.h
+++ b/src/libknot/rrtype/rdname.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2018 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2020 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
@@ -48,6 +48,13 @@ const knot_dname_t *knot_ns_name(const knot_rdata_t *rdata)
 	return rdata->data;
 }
 
+static inline
+const knot_dname_t *knot_ptr_name(const knot_rdata_t *rdata)
+{
+	assert(rdata);
+	return rdata->data;
+}
+
 static inline
 const knot_dname_t *knot_mx_name(const knot_rdata_t *rdata)
 {
@@ -69,6 +76,8 @@ const knot_dname_t *knot_rdata_name(const knot_rdata_t *rdata, uint16_t type)
 	switch (type) {
 		case KNOT_RRTYPE_NS:
 			return knot_ns_name(rdata);
+		case KNOT_RRTYPE_PTR:
+			return knot_ptr_name(rdata);
 		case KNOT_RRTYPE_MX:
 			return knot_mx_name(rdata);
 		case KNOT_RRTYPE_SRV:
diff --git a/src/utils/knotd/main.c b/src/utils/knotd/main.c
index e2ad5e01052477a4845c917f32a4659b7cab522a..7dc2b8d054105e9aae74daf9172bb68b6143b50a 100644
--- a/src/utils/knotd/main.c
+++ b/src/utils/knotd/main.c
@@ -51,6 +51,7 @@
 /* Signal flags. */
 static volatile bool sig_req_stop = false;
 static volatile bool sig_req_reload = false;
+static volatile bool sig_req_zones_reload = false;
 
 /* \brief Signal started state to the init system. */
 static void init_signal_started(void)
@@ -125,6 +126,7 @@ struct signal {
 /*! \brief Signals used by the server. */
 static const struct signal SIGNALS[] = {
 	{ SIGHUP,  true  },  /* Reload server. */
+	{ SIGUSR1, true  },  /* Reload zones. */
 	{ SIGINT,  true  },  /* Terminate server. */
 	{ SIGTERM, true  },  /* Terminate server. */
 	{ SIGALRM, false },  /* Internal thread synchronization. */
@@ -139,6 +141,9 @@ static void handle_signal(int signum)
 	case SIGHUP:
 		sig_req_reload = true;
 		break;
+	case SIGUSR1:
+		sig_req_zones_reload = true;
+		break;
 	case SIGINT:
 	case SIGTERM:
 		if (sig_req_stop) {
@@ -263,6 +268,10 @@ static void event_loop(server_t *server, const char *socket)
 			sig_req_reload = false;
 			server_reload(server);
 		}
+		if (sig_req_zones_reload) {
+			sig_req_zones_reload = false;
+			server_update_zones(conf(), server);
+		}
 
 		// Update control timeout.
 		knot_ctl_set_timeout(ctl, conf()->cache.ctl_timeout);
diff --git a/tests-extra/tests/zone/catalog/data/catalog1.zone b/tests-extra/tests/zone/catalog/data/catalog1.zone
new file mode 100644
index 0000000000000000000000000000000000000000..b72a57a33687a4a6ef049af60b4d264147968c6d
--- /dev/null
+++ b/tests-extra/tests/zone/catalog/data/catalog1.zone
@@ -0,0 +1,7 @@
+$ORIGIN catalog1.
+$TTL 0
+
+@ SOA ns admin 1 25 25 80 600
+  NS ns
+ns AAAA ::0
+foo.bar PTR cataloged1.
diff --git a/tests-extra/tests/zone/catalog/data/cataloged1.zone b/tests-extra/tests/zone/catalog/data/cataloged1.zone
new file mode 100644
index 0000000000000000000000000000000000000000..67031c7144f1ece5eec8f06746c6f0871e9e9597
--- /dev/null
+++ b/tests-extra/tests/zone/catalog/data/cataloged1.zone
@@ -0,0 +1,6 @@
+$ORIGIN cataloged1.
+$TTL 1200
+
+@ SOA ns admin 10001 25 25 80 600
+  NS ns
+ns AAAA ::0
diff --git a/tests-extra/tests/zone/catalog/data/cataloged2.zone b/tests-extra/tests/zone/catalog/data/cataloged2.zone
new file mode 100644
index 0000000000000000000000000000000000000000..29aea7fddfeff424a7bedea40c9f49cb25bdc87f
--- /dev/null
+++ b/tests-extra/tests/zone/catalog/data/cataloged2.zone
@@ -0,0 +1,6 @@
+$ORIGIN cataloged2.
+$TTL 1200
+
+@ SOA ns admin 1 25 25 80 600
+  NS ns
+ns AAAA ::0
diff --git a/tests-extra/tests/zone/catalog/test.py b/tests-extra/tests/zone/catalog/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..5077f9fa349c331e6738ba4a1b16652097211e1a
--- /dev/null
+++ b/tests-extra/tests/zone/catalog/test.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+
+'''Test of Catalog zones.'''
+
+from dnstest.test import Test
+from dnstest.utils import set_err, detail_log
+import dnstest.params
+
+import glob
+import shutil
+from subprocess import DEVNULL, PIPE, Popen
+import subprocess
+
+def check_keys(server, zone_name, expect_keys):
+    cmd = Popen([dnstest.params.keymgr_bin, "-d", server.dir + "/keys", zone_name, "list"], stdout=PIPE, stderr=PIPE, universal_newlines=True)
+    (stdout, stderr) = cmd.communicate()
+    lines = len(stdout.splitlines())
+    if lines != expect_keys:
+        set_err("CHECK # of KEYS (%d != %d)" % (lines, expect_keys))
+
+t = Test()
+
+master = t.server("knot")
+slave = t.server("knot")
+
+# Zone setup
+zone = t.zone("example.com.") + t.zone("catalog1.", storage=".")
+
+t.link(zone, master, slave, ixfr=True)
+
+master.zones["catalog1."].catalog = True
+slave.zones["catalog1."].catalog = True
+
+slave.dnssec(zone[1]).enable = True
+
+for zf in glob.glob(t.data_dir + "/*.zone"):
+    shutil.copy(zf, master.dir + "/master")
+
+t.start()
+
+# Basic: master a slave configure cataloged zone.
+t.sleep(5)
+resp = master.dig("cataloged1.", "SOA")
+resp.check(rcode="NOERROR")
+resp = slave.dig("cataloged1.", "DNSKEY", dnssec=True)
+resp.check(rcode="NOERROR")
+resp.check_count(2, "DNSKEY")
+resp.check_count(1, "RRSIG")
+
+# Udating a cataloged zone
+subprocess.run(["sed", "-i", "s/10001/10002/;$s/$/\\nxyz A 1.2.3.4/", master.dir + "/master/cataloged1.zone"])
+master.ctl("zone-reload cataloged1.")
+t.sleep(4)
+resp = slave.dig("xyz.cataloged1.", "A", dnssec=True)
+resp.check(rcode="NOERROR")
+resp.check_count(1, "RRSIG")
+
+check_keys(slave, "cataloged1", 2)
+
+# Check adding cataloged zone.
+up = master.update(zone[1])
+up.add("bar.catalog1.", 0, "PTR", "cataloged2.")
+up.send("NOERROR")
+t.sleep(6)
+resp = master.dig("cataloged2.", "NS")
+resp.check(rcode="NOERROR")
+resp = slave.dig("cataloged2.", "DNSKEY", dnssec=True)
+resp.check(rcode="NOERROR")
+resp.check_count(2, "DNSKEY")
+resp.check_count(1, "RRSIG")
+
+# Check that addition didn't delete prvious
+resp = master.dig("cataloged1.", "SOA")
+resp.check(rcode="NOERROR")
+resp = slave.dig("cataloged1.", "SOA", dnssec=True)
+resp.check(rcode="NOERROR")
+resp.check_count(1, "RRSIG")
+
+# Check remove-adding tha same catalog record: shall not purge it
+resp0 = slave.dig("cataloged2.", "DNSKEY")
+resp0.check_count(2, "DNSKEY")
+dnskey0 = resp0.resp.answer[0].to_rdataset()[0]
+up = master.update(zone[1])
+up.delete("bar.catalog1.", "PTR", "cataloged2.")
+up.add("bar.catalog1.", 0, "PTR", "cataloged2.")
+up.send("NOERROR")
+t.sleep(4)
+resp1 = slave.dig("cataloged2.", "DNSKEY")
+resp1.check_count(2, "DNSKEY")
+match = 0
+if resp1.count("DNSKEY") > 0:
+    for dnskey1 in resp1.resp.answer[0].to_rdataset():
+        if dnskey1.to_text() == dnskey0.to_text():
+             match = match + 1
+if match < 1:
+    set_err("ZONE PURGED")
+    dnskey1 = dnskey0
+else:
+    dnskey1 = resp1.resp.answer[0].to_rdataset()[0]
+
+# Check remove-adding the zone: shall effectively purge it
+up = master.update(zone[1])
+up.delete("bar.catalog1.", "PTR", "cataloged2.")
+up.add("bar2.catalog1.", 0, "PTR", "cataloged2.")
+up.send("NOERROR")
+t.sleep(4)
+shutil.copy(t.data_dir + "/cataloged2.zone", master.dir + "/master") # because the purge deletes even zonefile
+master.ctl("zone-reload cataloged2.")
+t.sleep(6)
+resp2 = slave.dig("cataloged2.", "DNSKEY")
+resp2.check_count(2, "DNSKEY")
+if resp2.count("DNSKEY") > 0:
+    for dnskey2 in resp2.resp.answer[0].to_rdataset():
+        if dnskey1.to_text() == dnskey2.to_text():
+            set_err("ZONE NOT PURGED")
+
+# Check persistence after server restart
+slave.stop()
+slave.start()
+t.sleep(8)
+resp = master.dig("cataloged1.", "SOA")
+resp.check(rcode="NOERROR")
+resp = slave.dig("cataloged1.", "SOA", dnssec=True)
+resp.check(rcode="NOERROR")
+resp.check_count(1, "RRSIG")
+resp = master.dig("cataloged2.", "SOA")
+resp.check(rcode="NOERROR")
+resp = slave.dig("cataloged2.", "SOA", dnssec=True)
+resp.check(rcode="NOERROR")
+resp.check_count(1, "RRSIG")
+
+# Check adding and removing duplicate
+up = master.update(zone[1])
+up.add("bar3.catalog1.", 0, "PTR", "cataloged2.")
+up.send("NOERROR")
+t.sleep(6)
+up = master.update(zone[1])
+up.delete("bar3.catalog1.", "PTR")
+up.send("NOERROR")
+t.sleep(6)
+resp = master.dig("cataloged2.", "SOA")
+resp.check(rcode="NOERROR")
+resp = slave.dig("cataloged2.", "SOA", dnssec=True)
+resp.check(rcode="NOERROR")
+check_keys(slave, "cataloged2", 2)
+
+# Check removing cataloged zone
+up = master.update(zone[1])
+up.delete("foo.bar.catalog1.", "PTR")
+up.send("NOERROR")
+t.sleep(6)
+resp = master.dig("cataloged1.", "SOA")
+resp.check(rcode="REFUSED")
+resp = slave.dig("cataloged1.", "DNSKEY")
+resp.check(rcode="REFUSED")
+check_keys(slave, "cataloged1", 0)
+
+# Check inaccessibility of catalog zone
+slave.ctl("conf-begin")
+slave.ctl("conf-unset zone[catalog1.].acl") # remove transfer-related ACLs
+slave.ctl("conf-commit")
+t.sleep(3)
+resp = slave.dig("abc.catalog1.", "A")
+resp.check(rcode="REFUSED")
+
+t.end()
diff --git a/tests-extra/tools/dnstest/server.py b/tests-extra/tools/dnstest/server.py
index 1114fa4ae6edc755f5c6932b8f3e8934856351a1..0b37f2c5799d61c03b9e67a33234f31a2c97a995 100644
--- a/tests-extra/tools/dnstest/server.py
+++ b/tests-extra/tools/dnstest/server.py
@@ -76,6 +76,7 @@ class Zone(object):
         self.journal_content = journal_content # journal contents
         self.modules = []
         self.dnssec = ZoneDnssec()
+        self.catalog = None
 
     @property
     def name(self):
@@ -1314,6 +1315,49 @@ class Knot(Server):
             s.item("global-module", "[%s]" % modules)
         if self.zone_size_limit:
             s.item("zone-max-size", self.zone_size_limit)
+
+        have_catalog = None
+        for zone in self.zones:
+            z = self.zones[zone]
+            if z.catalog:
+                have_catalog = z
+        if have_catalog is not None:
+            s.id_item("id", "catemplate")
+            s.item_str("file", self.dir + "/master/%s.zone")
+            s.item_str("zonefile-load", "difference")
+
+            # this is weird but for the sake of testing, the cataloged zones inherit dnssec policy from catalog zone
+            if z.dnssec.enable:
+                s.item_str("dnssec-signing", "on")
+                s.item_str("dnssec-policy", z.name)
+
+            acl = ""
+            if z.masters:
+                masters = ""
+                for master in z.masters:
+                    if masters:
+                        masters += ", "
+                    masters += master.name
+                    if not master.disable_notify:
+                        if acl:
+                            acl += ", "
+                        acl += "acl_%s" % master.name
+                s.item("master", "[%s]" % masters)
+            if z.slaves:
+                slaves = ""
+                for slave in z.slaves:
+                    if slave.disable_notify:
+                        continue
+                    if slaves:
+                        slaves += ", "
+                    slaves += slave.name
+                if slaves:
+                    s.item("notify", "[%s]" % slaves)
+            if acl:
+                acl += ", "
+            acl += "acl_local, acl_test"
+            s.item("acl", "[%s]" % acl)
+
         s.end()
 
         s.begin("zone")
@@ -1363,6 +1407,9 @@ class Knot(Server):
                 s.item_str("dnssec-signing", "on")
                 s.item_str("dnssec-policy", z.dnssec.shared_policy_with or z.name)
 
+            if z.catalog:
+                s.item_str("catalog-template", "catemplate")
+
             if len(z.modules) > 0:
                 modules = ""
                 for module in z.modules: