diff --git a/Knot.files b/Knot.files
index cbd9629f5e31ee0130b20a3e446d3c4d5af23c96..6ad59293bd7f08bc519201636ebfb1207921686f 100644
--- a/Knot.files
+++ b/Knot.files
@@ -208,6 +208,8 @@ src/knot/common/process.c
 src/knot/common/process.h
 src/knot/common/ref.c
 src/knot/common/ref.h
+src/knot/common/stats.c
+src/knot/common/stats.h
 src/knot/conf/base.c
 src/knot/conf/base.h
 src/knot/conf/conf.c
@@ -267,6 +269,8 @@ src/knot/modules/online_sign/online_sign.h
 src/knot/modules/rosedb/rosedb.c
 src/knot/modules/rosedb/rosedb.h
 src/knot/modules/rosedb/rosedb_tool.c
+src/knot/modules/stats/stats.c
+src/knot/modules/stats/stats.h
 src/knot/modules/synth_record/synth_record.c
 src/knot/modules/synth_record/synth_record.h
 src/knot/modules/whoami/whoami.c
diff --git a/src/Makefile.am b/src/Makefile.am
index 308e1206cae692738ac2c170dc0176895733f701..406b0089a490c35dbc49cb64b9db5140ecac85bd 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -280,6 +280,8 @@ libknotd_la_SOURCES =				\
 	knot/modules/online_sign/online_sign.h	\
 	knot/modules/online_sign/nsec_next.c	\
 	knot/modules/online_sign/nsec_next.h	\
+	knot/modules/stats/stats.c		\
+	knot/modules/stats/stats.h		\
 	knot/modules/synth_record/synth_record.c\
 	knot/modules/synth_record/synth_record.h\
 	knot/modules/whoami/whoami.c		\
@@ -323,6 +325,8 @@ libknotd_la_SOURCES =				\
 	knot/common/process.h			\
 	knot/common/ref.c			\
 	knot/common/ref.h			\
+	knot/common/stats.c			\
+	knot/common/stats.h			\
 	knot/server/dthreads.c			\
 	knot/server/dthreads.h			\
 	knot/server/journal.c			\
diff --git a/src/knot/common/stats.c b/src/knot/common/stats.c
new file mode 100644
index 0000000000000000000000000000000000000000..2c507f9fe92b5b2585f6b2cb1ec972297ca660c2
--- /dev/null
+++ b/src/knot/common/stats.c
@@ -0,0 +1,277 @@
+/*  Copyright (C) 2016 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 <http://www.gnu.org/licenses/>.
+ */
+
+#include <inttypes.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+#include <urcu.h>
+
+#include "contrib/files.h"
+#include "knot/common/stats.h"
+#include "knot/common/log.h"
+#include "knot/nameserver/query_module.h"
+
+struct {
+	bool active_dumper;
+	pthread_t dumper;
+	uint32_t timer;
+	server_t *server;
+} stats = { 0 };
+
+#define DUMP_STR(fd, level, name, ...) do { \
+	fprintf(fd, "%-.*s"name": %s\n", level, "    ", ##__VA_ARGS__); \
+	} while (0)
+#define DUMP_CTR(fd, level, name, ...) do { \
+	fprintf(fd, "%-.*s"name": %"PRIu64"\n", level, "    ", ##__VA_ARGS__); \
+	} while (0)
+
+uint64_t server_zone_count(server_t *server)
+{
+	return knot_zonedb_size(server->zone_db);
+}
+
+const stats_item_t server_stats[] = {
+	{ "zone-count", server_zone_count },
+	{ 0 }
+};
+
+static void dump_counters(FILE *fd, int level, mod_ctr_t *ctr)
+{
+	for (uint32_t j = 0; j < ctr->count; j++) {
+		// Skip empty counters.
+		if (ctr->counters[j] == 0) {
+			continue;
+		}
+
+		if (ctr->idx_to_str != NULL) {
+			char *str = ctr->idx_to_str(j, ctr->count);
+			if (str != NULL) {
+				DUMP_CTR(fd, level, "%s", str, ctr->counters[j]);
+				free(str);
+			}
+		} else {
+			DUMP_CTR(fd, level, "%u", j, ctr->counters[j]);
+		}
+	}
+}
+
+static void dump_modules(FILE *fd, list_t *query_modules, const knot_dname_t *zone)
+{
+	static int level = 0;
+	struct query_module *mod = NULL;
+	WALK_LIST(mod, *query_modules) {
+		// Skip modules without statistics.
+		if (mod->stats_count == 0) {
+			continue;
+		}
+
+		// Dump zone name.
+		if (zone != NULL) {
+			// Prevent from zone section override.
+			if (level == 0) {
+				DUMP_STR(fd, level++, "zone", "");
+			} else {
+				level = 1;
+			}
+
+			char name[KNOT_DNAME_TXT_MAXLEN + 1];
+			if (knot_dname_to_str(name, zone, sizeof(name)) == NULL) {
+				return;
+			}
+			DUMP_STR(fd, level++, "\"%s\"", name, "");
+		} else {
+			level = 0;
+		}
+
+		// Dump module counters.
+		DUMP_STR(fd, level, "%s", mod->id->name + 1, "");
+		for (int i = 0; i < mod->stats_count; i++) {
+			mod_ctr_t *ctr = mod->stats + i;
+			if (ctr->name == NULL) {
+				// Empty counter.
+				continue;
+			}
+			if (ctr->count == 1) {
+				// Simple counter.
+				DUMP_CTR(fd, level + 1, "%s", ctr->name, ctr->counter);
+			} else {
+				// Array of counters.
+				DUMP_STR(fd, level + 1, "%s", ctr->name, "");
+				dump_counters(fd, level + 2, ctr);
+			}
+		}
+	}
+}
+
+static void zone_stats_dump(zone_t *zone, FILE *fd)
+{
+	if (EMPTY_LIST(zone->query_modules)) {
+		return;
+	}
+
+	dump_modules(fd, &zone->query_modules, zone->name);
+}
+
+static void dump_to_file(FILE *fd, server_t *server)
+{
+	char date[64] = "";
+
+	// Get formated current time string.
+	struct tm tm;
+	time_t now = time(NULL);
+	localtime_r(&now, &tm);
+	strftime(date, sizeof(date), "%Y-%m-%dT%H:%M:%S%z", &tm);
+
+	// Get the server identity.
+	conf_val_t val = conf_get(conf(), C_SRV, C_IDENT);
+	const char *ident = conf_str(&val);
+	if (val.code != KNOT_EOK || ident[0] == '\0') {
+		ident = conf()->hostname;
+	}
+
+	// Dump record header.
+	fprintf(fd,
+	        "---\n"
+	        "time: %s\n"
+	        "identity: %s\n",
+	        date, ident);
+
+	// Dump server statistics.
+	DUMP_STR(fd, 0, "server", "");
+	for (const stats_item_t *item = server_stats; item->name != NULL; item++) {
+		DUMP_CTR(fd, 1, "%s", item->name, item->val(server));
+	}
+
+	// Dump global statistics.
+	dump_modules(fd, &conf()->query_modules, NULL);
+
+	// Dump zone statistics.
+	knot_zonedb_foreach(server->zone_db, zone_stats_dump, fd);
+}
+
+static void dump_stats(server_t *server)
+{
+	conf_val_t val = conf_get(conf(), C_SRV, C_RUNDIR);
+	char *rundir = conf_abs_path(&val, NULL);
+	val = conf_get(conf(), C_STATS, C_FILE);
+	char *file_name = conf_abs_path(&val, rundir);
+	free(rundir);
+
+	val = conf_get(conf(), C_STATS, C_APPEND);
+	bool append = conf_bool(&val);
+
+	// Open or create output file.
+	FILE *fd = NULL;
+	char *tmp_name = NULL;
+	if (append) {
+		fd = fopen(file_name, "a");
+		if (fd == NULL) {
+			log_error("stats, failed to append file '%s' (%s)",
+			          file_name, knot_strerror(knot_map_errno()));
+			free(file_name);
+			return;
+		}
+	} else {
+		int ret = open_tmp_file(file_name, &tmp_name, &fd,
+		                        S_IRUSR | S_IWUSR | S_IRGRP);
+		if (ret != KNOT_EOK) {
+			log_error("stats, failed to open file '%s' (%s)",
+			          file_name, knot_strerror(ret));
+			free(file_name);
+			return;
+		}
+	}
+	assert(fd);
+
+	// Dump stats into the file.
+	dump_to_file(fd, server);
+
+	fflush(fd);
+	fclose(fd);
+
+	// Switch the file contents.
+	if (!append) {
+		int ret = rename(tmp_name, file_name);
+		if (ret != 0) {
+			log_error("stats, failed to access file '%s' (%s)",
+			          file_name, knot_strerror(knot_map_errno()));
+			unlink(tmp_name);
+		}
+		free(tmp_name);
+	}
+
+	log_debug("stats, dumped into file '%s'", file_name);
+	free(file_name);
+}
+
+static void *dumper(void *data)
+{
+	while (true) {
+		assert(stats.timer > 0);
+		sleep(stats.timer);
+
+		pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
+		rcu_read_lock();
+		dump_stats(stats.server);
+		rcu_read_unlock();
+		pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
+	}
+
+	return NULL;
+}
+
+void stats_reconfigure(conf_t *conf, server_t *server)
+{
+	if (conf == NULL || server == NULL) {
+		return;
+	}
+
+	// Update server context.
+	stats.server = server;
+
+	conf_val_t val = conf_get(conf, C_STATS, C_TIMER);
+	stats.timer = conf_int(&val);
+	if (stats.timer > 0) {
+		// Check if dumping is already running.
+		if (stats.active_dumper) {
+			return;
+		}
+
+		int ret = pthread_create(&stats.dumper, NULL, dumper, NULL);
+		if (ret != 0) {
+			log_error("stats, failed to launch periodic dumping (%s)",
+			          knot_strerror(knot_map_errno_code(ret)));
+		} else {
+			stats.active_dumper = true;
+		}
+	// Stop current dumping.
+	} else if (stats.active_dumper) {
+		pthread_cancel(stats.dumper);
+		pthread_join(stats.dumper, NULL);
+		stats.active_dumper = false;
+	}
+}
+
+void stats_deinit(void)
+{
+	if (stats.active_dumper) {
+		pthread_cancel(stats.dumper);
+		pthread_join(stats.dumper, NULL);
+	}
+
+	memset(&stats, 0, sizeof(stats));
+}
diff --git a/src/knot/common/stats.h b/src/knot/common/stats.h
new file mode 100644
index 0000000000000000000000000000000000000000..9cbdd8b2d0e9f30c45c9f3a4bd1c2c42b666bae8
--- /dev/null
+++ b/src/knot/common/stats.h
@@ -0,0 +1,44 @@
+/*  Copyright (C) 2016 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 <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "knot/server/server.h"
+
+typedef uint64_t (*stats_val_f)(server_t *server);
+
+/*!
+ * \brief Statistics metrics item.
+ */
+typedef struct {
+	const char *name; /*!< Metrics name. */
+	stats_val_f val;  /*!< Metrics value getter. */
+} stats_item_t;
+
+/*!
+ * \brief Basic server metrics.
+ */
+extern const stats_item_t server_stats[];
+
+/*!
+ * \brief Reconfigures the statistics facility.
+ */
+void stats_reconfigure(conf_t *conf, server_t *server);
+
+/*!
+ * \brief Deinitializes the statistics facility.
+ */
+void stats_deinit(void);
diff --git a/src/knot/conf/scheme.c b/src/knot/conf/scheme.c
index 4ed01413e8ecebdcabc406a16806630cc793bcf8..af4e0596295563f94e67ec06701a50712ae7b440 100644
--- a/src/knot/conf/scheme.c
+++ b/src/knot/conf/scheme.c
@@ -29,6 +29,7 @@
 #include "dnssec/lib/dnssec/tsig.h"
 #include "dnssec/lib/dnssec/key.h"
 
+#include "knot/modules/stats/stats.h"
 #include "knot/modules/synth_record/synth_record.h"
 #include "knot/modules/dnsproxy/dnsproxy.h"
 #include "knot/modules/online_sign/online_sign.h"
@@ -148,6 +149,13 @@ static const yp_item_t desc_log[] = {
 	{ NULL }
 };
 
+static const yp_item_t desc_stats[] = {
+	{ C_TIMER,  YP_TINT,  YP_VINT = { 1, UINT32_MAX, 0, YP_STIME } },
+	{ C_FILE,   YP_TSTR,  YP_VSTR = { "stats.yaml" } },
+	{ C_APPEND, YP_TBOOL, YP_VNONE },
+	{ NULL }
+};
+
 static const yp_item_t desc_keystore[] = {
 	{ C_ID,      YP_TSTR, YP_VNONE },
 	{ C_BACKEND, YP_TOPT, YP_VOPT = { keystore_backends, KEYSTORE_BACKEND_PEM },
@@ -261,12 +269,14 @@ const yp_item_t conf_scheme[] = {
 	{ C_SRV,      YP_TGRP, YP_VGRP = { desc_server }, CONF_IO_FRLD_SRV },
 	{ C_CTL,      YP_TGRP, YP_VGRP = { desc_control } },
 	{ C_LOG,      YP_TGRP, YP_VGRP = { desc_log }, YP_FMULTI | CONF_IO_FRLD_LOG },
+	{ C_STATS,    YP_TGRP, YP_VGRP = { desc_stats }, CONF_IO_FRLD_SRV },
 	{ C_KEYSTORE, YP_TGRP, YP_VGRP = { desc_keystore }, YP_FMULTI, { check_keystore } },
 	{ C_POLICY,   YP_TGRP, YP_VGRP = { desc_policy }, YP_FMULTI, { check_policy } },
 	{ C_KEY,      YP_TGRP, YP_VGRP = { desc_key }, YP_FMULTI, { check_key } },
 	{ C_ACL,      YP_TGRP, YP_VGRP = { desc_acl }, YP_FMULTI, { check_acl } },
 	{ C_RMT,      YP_TGRP, YP_VGRP = { desc_remote }, YP_FMULTI, { check_remote } },
 /* MODULES */
+	{ C_MOD_STATS,        YP_TGRP, YP_VGRP = { scheme_mod_stats }, FMOD },
 	{ C_MOD_SYNTH_RECORD, YP_TGRP, YP_VGRP = { scheme_mod_synth_record }, FMOD,
 	                               { check_mod_synth_record } },
 	{ C_MOD_DNSPROXY,     YP_TGRP, YP_VGRP = { scheme_mod_dnsproxy }, FMOD,
diff --git a/src/knot/conf/scheme.h b/src/knot/conf/scheme.h
index 87546961cb865c8909e8b227b374f2c5cf301360..26820e917ac1c466ebcd18a1b055802f34c3cfeb 100644
--- a/src/knot/conf/scheme.h
+++ b/src/knot/conf/scheme.h
@@ -33,6 +33,7 @@
 #define C_ADDR			"\x07""address"
 #define C_ALG			"\x09""algorithm"
 #define C_ANY			"\x03""any"
+#define C_APPEND		"\x06""append"
 #define C_ASYNC_START		"\x0B""async-start"
 #define C_BACKEND		"\x07""backend"
 #define C_BG_WORKERS		"\x12""background-workers"
@@ -91,6 +92,8 @@
 #define C_SERIAL_POLICY		"\x0D""serial-policy"
 #define C_SERVER		"\x06""server"
 #define C_SRV			"\x06""server"
+#define C_STATS			"\x0A""statistics"
+#define C_TIMER			"\x05""timer"
 #define C_STORAGE		"\x07""storage"
 #define C_TARGET		"\x06""target"
 #define C_TCP_HSHAKE_TIMEOUT	"\x15""tcp-handshake-timeout"
diff --git a/src/knot/ctl/commands.c b/src/knot/ctl/commands.c
index a70e34ff49b76eb375a8928e12f8fb860c1a01da..609c185f458764337d34ab80346f0c201195d65f 100644
--- a/src/knot/ctl/commands.c
+++ b/src/knot/ctl/commands.c
@@ -18,9 +18,11 @@
 #include <unistd.h>
 
 #include "knot/common/log.h"
+#include "knot/common/stats.h"
 #include "knot/conf/confio.h"
 #include "knot/ctl/commands.h"
 #include "knot/events/handlers.h"
+#include "knot/nameserver/query_module.h"
 #include "knot/updates/zone-update.h"
 #include "knot/zone/timers.h"
 #include "libknot/libknot.h"
@@ -29,6 +31,7 @@
 #include "contrib/mempattern.h"
 #include "contrib/string.h"
 #include "zscanner/scanner.h"
+#include "contrib/strtonum.h"
 
 void ctl_log_data(knot_ctl_data_t *data)
 {
@@ -938,6 +941,150 @@ static int zone_purge(zone_t *zone, ctl_args_t *args)
 	return KNOT_EOK;
 }
 
+static int send_stats_ctr(mod_ctr_t *ctr, ctl_args_t *args, knot_ctl_data_t *data)
+{
+	char index[128];
+	char value[32];
+
+	if (ctr->count == 1) {
+		int ret = snprintf(value, sizeof(value), "%"PRIu64, ctr->counter);
+		if (ret <= 0 || ret >= sizeof(value)) {
+			return ret;
+		}
+
+		(*data)[KNOT_CTL_IDX_ID] = NULL;
+		(*data)[KNOT_CTL_IDX_DATA] = value;
+
+		ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, data);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+	} else {
+		bool force = ctl_has_flag(args->data[KNOT_CTL_IDX_FLAGS],
+		                          CTL_FLAG_FORCE);
+
+		for (uint32_t i = 0; i < ctr->count; i++) {
+			// Skip empty counters.
+			if (ctr->counters[i] == 0 && !force) {
+				continue;
+			}
+
+			int ret;
+			if (ctr->idx_to_str) {
+				char *str = ctr->idx_to_str(i, ctr->count);
+				if (str == NULL) {
+					continue;
+				}
+				ret = snprintf(index, sizeof(index), "%s", str);
+				free(str);
+			} else {
+				ret = snprintf(index, sizeof(index), "%u", i);
+			}
+			if (ret <= 0 || ret >= sizeof(index)) {
+				return ret;
+			}
+
+			ret = snprintf(value, sizeof(value),  "%"PRIu64,
+			               ctr->counters[i]);
+			if (ret <= 0 || ret >= sizeof(value)) {
+				return ret;
+			}
+
+			(*data)[KNOT_CTL_IDX_ID] = index;
+			(*data)[KNOT_CTL_IDX_DATA] = value;
+
+			knot_ctl_type_t type = (i == 0) ? KNOT_CTL_TYPE_DATA :
+			                                  KNOT_CTL_TYPE_EXTRA;
+			ret = knot_ctl_send(args->ctl, type, data);
+			if (ret != KNOT_EOK) {
+				return ret;
+			}
+		}
+	}
+
+	return KNOT_EOK;
+}
+
+static int modules_stats(list_t *query_modules, ctl_args_t *args, knot_dname_t *zone)
+{
+	if (query_modules == NULL) {
+		return KNOT_EOK;
+	}
+
+	const char *section = args->data[KNOT_CTL_IDX_SECTION];
+	const char *item = args->data[KNOT_CTL_IDX_ITEM];
+
+	char name[KNOT_DNAME_TXT_MAXLEN + 1] = { 0 };
+	knot_ctl_data_t data = { 0 };
+
+	bool section_found = (section == NULL) ? true : false;
+	bool item_found = (item == NULL) ? true : false;
+
+	struct query_module *mod = NULL;
+	WALK_LIST(mod, *query_modules) {
+		// Skip modules without statistics.
+		if (mod->stats_count == 0) {
+			continue;
+		}
+
+		// Check for specific module.
+		if (section != NULL) {
+			if (section_found) {
+				break;
+			} else if (strcasecmp(mod->id->name + 1, section) == 0) {
+				section_found = true;
+			} else {
+				continue;
+			}
+		}
+
+		data[KNOT_CTL_IDX_SECTION] = mod->id->name + 1;
+
+		for (int i = 0; i < mod->stats_count; i++) {
+			mod_ctr_t *ctr = mod->stats + i;
+
+			// Skip empty counter.
+			if (ctr->name == NULL) {
+				continue;
+			}
+
+			// Check for specific counter.
+			if (item != NULL) {
+				if (item_found) {
+					break;
+				} else if (strcasecmp(ctr->name, item) == 0) {
+					item_found = true;
+				} else {
+					continue;
+				}
+			}
+
+			// Prepare zone name if not already prepared.
+			if (zone != NULL && name[0] == '\0') {
+				if (knot_dname_to_str(name, zone, sizeof(name)) == NULL) {
+					return KNOT_EINVAL;
+				}
+				data[KNOT_CTL_IDX_ZONE] = name;
+			}
+
+			data[KNOT_CTL_IDX_ITEM] = ctr->name;
+
+			// Send the counters.
+			int ret = send_stats_ctr(ctr, args, &data);
+			if (ret != KNOT_EOK) {
+				return ret;
+			}
+		}
+	}
+
+	return (section_found && item_found) ? KNOT_EOK : KNOT_ENOENT;
+}
+
+static int zone_stats(zone_t *zone, ctl_args_t *args)
+{
+	return modules_stats(&zone->query_modules, args, zone->name);
+}
+
 static int ctl_zone(ctl_args_t *args, ctl_cmd_t cmd)
 {
 	switch (cmd) {
@@ -971,6 +1118,8 @@ static int ctl_zone(ctl_args_t *args, ctl_cmd_t cmd)
 		return zones_apply(args, zone_txn_unset);
 	case CTL_ZONE_PURGE:
 		return zones_apply(args, zone_purge);
+	case CTL_ZONE_STATS:
+		return zones_apply(args, zone_stats);
 	default:
 		assert(0);
 		return KNOT_EINVAL;
@@ -1002,6 +1151,69 @@ static int ctl_server(ctl_args_t *args, ctl_cmd_t cmd)
 	return ret;
 }
 
+static int ctl_stats(ctl_args_t *args, ctl_cmd_t cmd)
+{
+	const char *section = args->data[KNOT_CTL_IDX_SECTION];
+	const char *item = args->data[KNOT_CTL_IDX_ITEM];
+
+	bool found = (section == NULL) ? true : false;
+
+	// Process server metrics.
+	if (section == NULL || strcasecmp(section, "server") == 0) {
+		char value[32];
+		knot_ctl_data_t data = {
+			[KNOT_CTL_IDX_SECTION] = "server",
+			[KNOT_CTL_IDX_DATA] = value
+		};
+
+		for (const stats_item_t *i = server_stats; i->name != NULL; i++) {
+			if (item != NULL) {
+				if (found) {
+					break;
+				} else if (strcmp(i->name, item) == 0) {
+					found = true;
+				} else {
+					continue;
+				}
+			} else {
+				found = true;
+			}
+
+			data[KNOT_CTL_IDX_ITEM] = i->name;
+			int ret = snprintf(value, sizeof(value), "%"PRIu64,
+			                   i->val(args->server));
+			if (ret <= 0 || ret >= sizeof(value)) {
+				send_error(args, knot_strerror(ret));
+				return ret;
+			}
+
+			ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
+			if (ret != KNOT_EOK) {
+				send_error(args, knot_strerror(ret));
+				return ret;
+			}
+		}
+	}
+
+	// Process modules metrics.
+	if (section == NULL || strncasecmp(section, "mod-", strlen("mod-")) == 0) {
+		int ret = modules_stats(&conf()->query_modules, args, NULL);
+		if (ret != KNOT_EOK) {
+			send_error(args, knot_strerror(ret));
+			return ret;
+		}
+
+		found = true;
+	}
+
+	if (!found) {
+		send_error(args, knot_strerror(KNOT_EINVAL));
+		return KNOT_EINVAL;
+	}
+
+	return KNOT_EOK;
+}
+
 static int send_block_data(conf_io_t *io, knot_ctl_data_t *data)
 {
 	knot_ctl_t *ctl = (knot_ctl_t *)io->misc;
@@ -1266,6 +1478,7 @@ static const desc_t cmd_table[] = {
 	[CTL_STATUS]          = { "status",          ctl_server },
 	[CTL_STOP]            = { "stop",            ctl_server },
 	[CTL_RELOAD]          = { "reload",          ctl_server },
+	[CTL_STATS]           = { "stats",           ctl_stats },
 
 	[CTL_ZONE_STATUS]     = { "zone-status",     ctl_zone },
 	[CTL_ZONE_RELOAD]     = { "zone-reload",     ctl_zone },
@@ -1283,6 +1496,7 @@ static const desc_t cmd_table[] = {
 	[CTL_ZONE_SET]        = { "zone-set",        ctl_zone },
 	[CTL_ZONE_UNSET]      = { "zone-unset",      ctl_zone },
 	[CTL_ZONE_PURGE]      = { "zone-purge",      ctl_zone },
+	[CTL_ZONE_STATS]      = { "zone-stats",	     ctl_zone },
 
 	[CTL_CONF_LIST]       = { "conf-list",       ctl_conf_read },
 	[CTL_CONF_READ]       = { "conf-read",       ctl_conf_read },
diff --git a/src/knot/ctl/commands.h b/src/knot/ctl/commands.h
index 204bb17d604f0ed572fcd60415baf7037e304074..430fc8212281ba6883c34e0cd86c445ae9a50ff4 100644
--- a/src/knot/ctl/commands.h
+++ b/src/knot/ctl/commands.h
@@ -38,6 +38,7 @@ typedef enum {
 	CTL_STATUS,
 	CTL_STOP,
 	CTL_RELOAD,
+	CTL_STATS,
 
 	CTL_ZONE_STATUS,
 	CTL_ZONE_RELOAD,
@@ -45,6 +46,7 @@ typedef enum {
 	CTL_ZONE_RETRANSFER,
 	CTL_ZONE_FLUSH,
 	CTL_ZONE_SIGN,
+	CTL_ZONE_STATS,
 
 	CTL_ZONE_READ,
 	CTL_ZONE_BEGIN,
diff --git a/src/knot/modules/stats/stats.c b/src/knot/modules/stats/stats.c
new file mode 100644
index 0000000000000000000000000000000000000000..325f6b61053c7ee334e7c9856e71b79bfba56ff8
--- /dev/null
+++ b/src/knot/modules/stats/stats.c
@@ -0,0 +1,566 @@
+/*  Copyright (C) 2016 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 <http://www.gnu.org/licenses/>.
+ */
+
+#include "contrib/mempattern.h"
+#include "libknot/libknot.h"
+#include "knot/modules/stats/stats.h"
+#include "knot/nameserver/axfr.h"
+
+#define MOD_PROTOCOL	"\x10""request-protocol"
+#define MOD_OPERATION	"\x10""server-operation"
+#define MOD_REQ_BYTES	"\x0D""request-bytes"
+#define MOD_RESP_BYTES	"\x0E""response-bytes"
+#define MOD_EDNS	"\x0D""edns-presence"
+#define MOD_FLAG	"\x0D""flag-presence"
+#define MOD_RCODE	"\x0D""response-code"
+#define MOD_NODATA	"\x0C""reply-nodata"
+#define MOD_QTYPE	"\x0A""query-type"
+#define MOD_QSIZE	"\x0A""query-size"
+#define MOD_RSIZE	"\x0A""reply-size"
+
+#define OTHER		"other"
+
+const yp_item_t scheme_mod_stats[] = {
+	{ C_ID,           YP_TSTR,  YP_VNONE },
+	{ MOD_PROTOCOL,   YP_TBOOL, YP_VBOOL = { true } },
+	{ MOD_OPERATION,  YP_TBOOL, YP_VBOOL = { true } },
+	{ MOD_REQ_BYTES,  YP_TBOOL, YP_VBOOL = { true } },
+	{ MOD_RESP_BYTES, YP_TBOOL, YP_VBOOL = { true } },
+	{ MOD_EDNS,       YP_TBOOL, YP_VNONE },
+	{ MOD_FLAG,       YP_TBOOL, YP_VNONE },
+	{ MOD_RCODE,      YP_TBOOL, YP_VBOOL = { true } },
+	{ MOD_NODATA,     YP_TBOOL, YP_VNONE },
+	{ MOD_QTYPE,      YP_TBOOL, YP_VNONE },
+	{ MOD_QSIZE,      YP_TBOOL, YP_VNONE },
+	{ MOD_RSIZE,      YP_TBOOL, YP_VNONE },
+	{ C_COMMENT,      YP_TSTR,  YP_VNONE },
+	{ NULL }
+};
+
+enum {
+	CTR_PROTOCOL,
+	CTR_OPERATION,
+	CTR_REQ_BYTES,
+	CTR_RESP_BYTES,
+	CTR_EDNS,
+	CTR_FLAG,
+	CTR_RCODE,
+	CTR_NODATA,
+	CTR_QTYPE,
+	CTR_QSIZE,
+	CTR_RSIZE,
+};
+
+typedef struct {
+	mod_ctr_t *counters;
+	bool protocol;
+	bool operation;
+	bool req_bytes;
+	bool resp_bytes;
+	bool edns;
+	bool flag;
+	bool rcode;
+	bool nodata;
+	bool qtype;
+	bool qsize;
+	bool rsize;
+} stats_t;
+
+typedef struct {
+	yp_name_t *conf_name;
+	size_t conf_offset;
+	uint32_t count;
+	mod_idx_to_str_f fcn;
+} ctr_desc_t;
+
+enum {
+	OPERATION_QUERY = 0,
+	OPERATION_UPDATE,
+	OPERATION_NOTIFY,
+	OPERATION_AXFR,
+	OPERATION_IXFR,
+	OPERATION_INVALID,
+	OPERATION__COUNT
+};
+
+static char *operation_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case OPERATION_QUERY:   return strdup("query");
+	case OPERATION_UPDATE:  return strdup("update");
+	case OPERATION_NOTIFY:  return strdup("notify");
+	case OPERATION_AXFR:    return strdup("axfr");
+	case OPERATION_IXFR:    return strdup("ixfr");
+	case OPERATION_INVALID: return strdup("invalid");
+	default:                assert(0); return NULL;
+	}
+}
+
+enum {
+	PROTOCOL_UDP4 = 0,
+	PROTOCOL_TCP4,
+	PROTOCOL_UDP6,
+	PROTOCOL_TCP6,
+	PROTOCOL__COUNT
+};
+
+static char *protocol_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case PROTOCOL_UDP4: return strdup("udp4");
+	case PROTOCOL_TCP4: return strdup("tcp4");
+	case PROTOCOL_UDP6: return strdup("udp6");
+	case PROTOCOL_TCP6: return strdup("tcp6");
+	default:            assert(0); return NULL;
+	}
+}
+
+enum {
+	REQ_BYTES_QUERY = 0,
+	REQ_BYTES_UPDATE,
+	REQ_BYTES_OTHER,
+	REQ_BYTES__COUNT
+};
+
+static char *req_bytes_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case REQ_BYTES_QUERY:  return strdup("query");
+	case REQ_BYTES_UPDATE: return strdup("update");
+	case REQ_BYTES_OTHER:  return strdup(OTHER);
+	default:               assert(0); return NULL;
+	}
+}
+
+enum {
+	RESP_BYTES_REPLY = 0,
+	RESP_BYTES_TRANSFER,
+	RESP_BYTES_OTHER,
+	RESP_BYTES__COUNT
+};
+
+static char *resp_bytes_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case RESP_BYTES_REPLY:    return strdup("reply");
+	case RESP_BYTES_TRANSFER: return strdup("transfer");
+	case RESP_BYTES_OTHER:    return strdup(OTHER);
+	default:                  assert(0); return NULL;
+	}
+}
+
+enum {
+	EDNS_REQ = 0,
+	EDNS_RESP,
+	EDNS__COUNT
+};
+
+static char *edns_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case EDNS_REQ:  return strdup("request");
+	case EDNS_RESP: return strdup("response");
+	default:        assert(0); return NULL;
+	}
+}
+
+enum {
+	FLAG_DO = 0,
+	FLAG_TC,
+	FLAG__COUNT
+};
+
+static char *flag_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case FLAG_TC: return strdup("TC");
+	case FLAG_DO: return strdup("DO");
+	default:      assert(0); return NULL;
+	}
+}
+
+enum {
+	NODATA_A = 0,
+	NODATA_AAAA,
+	NODATA_OTHER,
+	NODATA__COUNT
+};
+
+static char *nodata_to_str(uint32_t idx, uint32_t count)
+{
+	switch (idx) {
+	case NODATA_A:     return strdup("A");
+	case NODATA_AAAA:  return strdup("AAAA");
+	case NODATA_OTHER: return strdup(OTHER);
+	default:           assert(0); return NULL;
+	}
+}
+
+#define RCODE_BADSIG	15 // Unassigned code internally used for BADSIG.
+#define RCODE_OTHER	(KNOT_RCODE_BADCOOKIE + 1) // Other RCODES.
+
+static char *rcode_to_str(uint32_t idx, uint32_t count)
+{
+	const knot_lookup_t *rcode = NULL;
+
+	switch (idx) {
+	case RCODE_BADSIG:
+		rcode = knot_lookup_by_id(knot_tsig_rcode_names, KNOT_RCODE_BADSIG);
+		break;
+	case RCODE_OTHER:
+		return strdup(OTHER);
+	default:
+		rcode = knot_lookup_by_id(knot_rcode_names, idx);
+		break;
+	}
+
+	if (rcode != NULL) {
+		return strdup(rcode->name);
+	} else {
+		return NULL;
+	}
+}
+
+enum {
+	QTYPE_OTHER  =   0,
+	QTYPE_MIN1   =   1,
+	QTYPE_MAX1   =  65,
+	QTYPE_MIN2   =  99,
+	QTYPE_MAX2   = 110,
+	QTYPE_MIN3   = 255,
+	QTYPE_MAX3   = 260,
+	QTYPE_SHIFT2 = QTYPE_MIN2 - QTYPE_MAX1 - 1,
+	QTYPE_SHIFT3 = QTYPE_SHIFT2 + QTYPE_MIN3 - QTYPE_MAX2 - 1,
+	QTYPE__COUNT = QTYPE_MAX3 - QTYPE_SHIFT3 + 1
+};
+
+static char *qtype_to_str(uint32_t idx, uint32_t count)
+{
+	if (idx == QTYPE_OTHER) {
+		return strdup(OTHER);
+	}
+
+	uint16_t qtype;
+
+	if (idx <= QTYPE_MAX1) {
+		qtype = idx;
+		assert(qtype >= QTYPE_MIN1 && qtype <= QTYPE_MAX1);
+	} else if (idx <= QTYPE_MAX2 - QTYPE_SHIFT2) {
+		qtype = idx + QTYPE_SHIFT2;
+		assert(qtype >= QTYPE_MIN2 && qtype <= QTYPE_MAX2);
+	} else {
+		qtype = idx + QTYPE_SHIFT3;
+		assert(qtype >= QTYPE_MIN3 && qtype <= QTYPE_MAX3);
+	}
+
+	char str[32];
+	if (knot_rrtype_to_string(qtype, str, sizeof(str)) < 0) {
+		return NULL;
+	} else {
+		return strdup(str);
+	}
+}
+
+#define BUCKET_SIZE	16
+
+static char *size_to_str(uint32_t idx, uint32_t count)
+{
+	char str[16];
+
+	int ret;
+	if (idx < count - 1) {
+		ret = snprintf(str, sizeof(str), "%u-%u", idx * BUCKET_SIZE,
+		               (idx + 1) * BUCKET_SIZE - 1);
+	} else {
+		ret = snprintf(str, sizeof(str), "%u-65535", idx * BUCKET_SIZE);
+	}
+
+	if (ret <= 0 || (size_t)ret >= sizeof(str)) {
+		return NULL;
+	} else {
+		return strdup(str);
+	}
+}
+
+static char *qsize_to_str(uint32_t idx, uint32_t count) {
+	return size_to_str(idx, count);
+}
+
+static char *rsize_to_str(uint32_t idx, uint32_t count) {
+	return size_to_str(idx, count);
+}
+
+static const ctr_desc_t ctr_descs[] = {
+	#define item(macro, name, count) \
+		[CTR_##macro] = { MOD_##macro, offsetof(stats_t, name), (count), name##_to_str }
+	item(PROTOCOL,   protocol,   PROTOCOL__COUNT),
+	item(OPERATION,  operation,  OPERATION__COUNT),
+	item(REQ_BYTES,  req_bytes,  REQ_BYTES__COUNT),
+	item(RESP_BYTES, resp_bytes, RESP_BYTES__COUNT),
+	item(EDNS,       edns,       EDNS__COUNT),
+	item(FLAG,       flag,       FLAG__COUNT),
+	item(RCODE,      rcode,      RCODE_OTHER + 1),
+	item(NODATA,     nodata,     NODATA__COUNT),
+	item(QTYPE,      qtype,      QTYPE__COUNT),
+	item(QSIZE,      qsize,      288 / BUCKET_SIZE + 1),
+	item(RSIZE,      rsize,      4096 / BUCKET_SIZE + 1),
+	{ NULL }
+};
+
+static int update_counters(int state, knot_pkt_t *pkt, struct query_data *qdata, void *ctx)
+{
+	assert(pkt && qdata && ctx);
+
+	stats_t *stats = ctx;
+
+	uint16_t operation;
+	unsigned xfr_packets = 0;
+
+	// Get the server operation.
+	switch (qdata->packet_type) {
+	case KNOT_QUERY_NORMAL:
+		operation = OPERATION_QUERY;
+		break;
+	case KNOT_QUERY_UPDATE:
+		operation = OPERATION_UPDATE;
+		break;
+	case KNOT_QUERY_NOTIFY:
+		operation = OPERATION_NOTIFY;
+		break;
+	case KNOT_QUERY_AXFR:
+		operation = OPERATION_AXFR;
+		if (qdata->ext != NULL) {
+			xfr_packets = ((struct xfr_proc *)qdata->ext)->npkts;
+		}
+		break;
+	case KNOT_QUERY_IXFR:
+		operation = OPERATION_IXFR;
+		if (qdata->ext != NULL) {
+			xfr_packets = ((struct xfr_proc *)qdata->ext)->npkts;
+		}
+		break;
+	default:
+		operation = OPERATION_INVALID;
+		break;
+	}
+
+	// Count request bytes.
+	if (stats->req_bytes) {
+		switch (operation) {
+		case OPERATION_QUERY:
+			mod_ctrs_incr(stats->counters, CTR_REQ_BYTES,
+			              REQ_BYTES_QUERY, qdata->query->size);
+			break;
+		case OPERATION_UPDATE:
+			mod_ctrs_incr(stats->counters, CTR_REQ_BYTES,
+			              REQ_BYTES_UPDATE, qdata->query->size);
+			break;
+		default:
+			if (xfr_packets <= 1) {
+				mod_ctrs_incr(stats->counters, CTR_REQ_BYTES,
+				              REQ_BYTES_OTHER, qdata->query->size);
+			}
+			break;
+		}
+	}
+
+	// Count response bytes.
+	if (stats->resp_bytes) {
+		switch (operation) {
+		case OPERATION_QUERY:
+			mod_ctrs_incr(stats->counters, CTR_RESP_BYTES,
+			              RESP_BYTES_REPLY, pkt->size);
+			break;
+		case OPERATION_AXFR:
+		case OPERATION_IXFR:
+			mod_ctrs_incr(stats->counters, CTR_RESP_BYTES,
+			              RESP_BYTES_TRANSFER, pkt->size);
+			break;
+		default:
+			mod_ctrs_incr(stats->counters, CTR_RESP_BYTES,
+			              RESP_BYTES_OTHER, pkt->size);
+			break;
+		}
+	}
+
+	// Get the extended response code.
+	uint16_t rcode = qdata->rcode;
+	if (qdata->rcode_tsig != KNOT_RCODE_NOERROR) {
+		rcode = qdata->rcode_tsig;
+	}
+
+	// Count the response code.
+	if (stats->rcode && pkt->size > 0) {
+		if (xfr_packets <= 1 || rcode != KNOT_RCODE_NOERROR) {
+			if (xfr_packets > 1) {
+				assert(rcode != KNOT_RCODE_NOERROR);
+				// Ignore the leading XFR message NOERROR.
+				mod_ctrs_decr(stats->counters, CTR_RCODE,
+				              KNOT_RCODE_NOERROR, 1);
+			}
+
+			if (qdata->rcode_tsig == KNOT_RCODE_BADSIG) {
+				mod_ctrs_incr(stats->counters, CTR_RCODE,
+				              RCODE_BADSIG, 1);
+			} else {
+				mod_ctrs_incr(stats->counters, CTR_RCODE,
+				              rcode, 1);
+			}
+		}
+	}
+
+	// Return if non-first transfer message.
+	if (xfr_packets > 1) {
+		return state;
+	}
+
+	// Count the server opearation.
+	if (stats->operation) {
+		mod_ctrs_incr(stats->counters, CTR_OPERATION, operation, 1);
+	}
+
+	// Count the request protocol.
+	if (stats->protocol) {
+		if (qdata->param->remote->ss_family == AF_INET) {
+			if (qdata->param->proc_flags & NS_QUERY_LIMIT_SIZE) {
+				mod_ctrs_incr(stats->counters, CTR_PROTOCOL,
+				              PROTOCOL_UDP4, 1);
+			} else {
+				mod_ctrs_incr(stats->counters, CTR_PROTOCOL,
+				              PROTOCOL_TCP4, 1);
+			}
+		} else {
+			if (qdata->param->proc_flags & NS_QUERY_LIMIT_SIZE) {
+				mod_ctrs_incr(stats->counters, CTR_PROTOCOL,
+				              PROTOCOL_UDP6, 1);
+			} else {
+				mod_ctrs_incr(stats->counters, CTR_PROTOCOL,
+				              PROTOCOL_TCP6, 1);
+			}
+		}
+	}
+
+	// Count EDNS occurrences.
+	if (stats->edns) {
+		if (qdata->query->opt_rr != NULL) {
+			mod_ctrs_incr(stats->counters, CTR_EDNS, EDNS_REQ, 1);
+		}
+		if (pkt->opt_rr != NULL && pkt->size > 0) {
+			mod_ctrs_incr(stats->counters, CTR_EDNS, EDNS_RESP, 1);
+		}
+	}
+
+	// Count interesting message header flags.
+	if (stats->flag) {
+		if (pkt->size > 0 && knot_wire_get_tc(pkt->wire)) {
+			mod_ctrs_incr(stats->counters, CTR_FLAG, FLAG_TC, 1);
+		}
+		if (pkt->opt_rr != NULL && knot_edns_do(pkt->opt_rr)) {
+			mod_ctrs_incr(stats->counters, CTR_FLAG, FLAG_DO, 1);
+		}
+	}
+
+	// Return if not query operation.
+	if (operation != OPERATION_QUERY) {
+		return state;
+	}
+
+	// Count NODATA reply (RFC 2308, Section 2.2).
+	if (stats->nodata && rcode == KNOT_RCODE_NOERROR && pkt->size > 0 &&
+	    knot_wire_get_ancount(pkt->wire) == 0 && !knot_wire_get_tc(pkt->wire) &&
+	    (knot_wire_get_nscount(pkt->wire) == 0 ||
+	     knot_pkt_rr(knot_pkt_section(pkt, KNOT_AUTHORITY), 0)->type == KNOT_RRTYPE_SOA)) {
+		switch (knot_pkt_qtype(qdata->query)) {
+		case KNOT_RRTYPE_A:
+			mod_ctrs_incr(stats->counters, CTR_NODATA, NODATA_A, 1);
+			break;
+		case KNOT_RRTYPE_AAAA:
+			mod_ctrs_incr(stats->counters, CTR_NODATA, NODATA_AAAA, 1);
+			break;
+		default:
+			mod_ctrs_incr(stats->counters, CTR_NODATA, NODATA_OTHER, 1);
+			break;
+		}
+	}
+
+	// Count the query type.
+	if (stats->qtype) {
+		uint16_t qtype = knot_pkt_qtype(qdata->query);
+
+		uint16_t idx;
+		switch (qtype) {
+		case QTYPE_MIN1 ... QTYPE_MAX1: idx = qtype; break;
+		case QTYPE_MIN2 ... QTYPE_MAX2: idx = qtype - QTYPE_SHIFT2; break;
+		case QTYPE_MIN3 ... QTYPE_MAX3: idx = qtype - QTYPE_SHIFT3; break;
+		default:                        idx = QTYPE_OTHER; break;
+		}
+
+		mod_ctrs_incr(stats->counters, CTR_QTYPE, idx, 1);
+	}
+
+	// Count the query size.
+	if (stats->qsize) {
+		mod_ctrs_incr(stats->counters, CTR_QSIZE,
+		              qdata->query->size / BUCKET_SIZE, 1);
+	}
+
+	// Count the reply size.
+	if (stats->rsize && pkt->size > 0) {
+		mod_ctrs_incr(stats->counters, CTR_RSIZE,
+		              pkt->size / BUCKET_SIZE, 1);
+	}
+
+	return state;
+}
+
+int stats_load(struct query_plan *plan, struct query_module *self,
+               const knot_dname_t *zone)
+{
+	assert(self);
+
+	stats_t *stats = mm_alloc(self->mm, sizeof(*stats));
+	if (stats == NULL) {
+		return KNOT_ENOMEM;
+	}
+
+	for (const ctr_desc_t *desc = ctr_descs; desc->conf_name != NULL; desc++) {
+		conf_val_t val = conf_mod_get(self->config, desc->conf_name, self->id);
+		bool enabled = conf_bool(&val);
+
+		// Initialize corresponding configuration item.
+		*(bool *)((uint8_t *)stats + desc->conf_offset) = enabled;
+
+		int ret = mod_stats_add(self, enabled ? desc->conf_name + 1 : NULL,
+		                        desc->count, desc->fcn);
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
+	}
+
+	stats->counters = self->stats;
+	self->ctx = stats;
+
+	return query_plan_step(plan, QPLAN_END, update_counters, self->ctx);
+}
+
+void stats_unload(struct query_module *self)
+{
+	assert(self);
+
+	stats_t *stats = self->ctx;
+
+	mm_free(self->mm, stats);
+}
diff --git a/src/knot/modules/stats/stats.h b/src/knot/modules/stats/stats.h
new file mode 100644
index 0000000000000000000000000000000000000000..bb8449d255b232eeb1997f514c3b9d23a459ef5d
--- /dev/null
+++ b/src/knot/modules/stats/stats.h
@@ -0,0 +1,28 @@
+/*  Copyright (C) 2016 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 <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "knot/nameserver/query_module.h"
+
+/*! \brief Module scheme. */
+#define C_MOD_STATS "\x09""mod-stats"
+extern const yp_item_t scheme_mod_stats[];
+
+/*! \brief Module interface. */
+int stats_load(struct query_plan *plan, struct query_module *self,
+               const knot_dname_t *zone);
+void stats_unload(struct query_module *self);
diff --git a/src/knot/nameserver/query_module.c b/src/knot/nameserver/query_module.c
index cc721e166aa0be508a5ee81adc1404fcd5aa49c9..8e0ca7d7c55a4b607d5569015f3c54bd0f85e715 100644
--- a/src/knot/nameserver/query_module.c
+++ b/src/knot/nameserver/query_module.c
@@ -14,9 +14,12 @@
     along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+#include <assert.h>
+
 #include "knot/nameserver/query_module.h"
 #include "contrib/mempattern.h"
 
+#include "knot/modules/stats/stats.h"
 #include "knot/modules/synth_record/synth_record.h"
 #include "knot/modules/dnsproxy/dnsproxy.h"
 #include "knot/modules/online_sign/online_sign.h"
@@ -34,6 +37,7 @@ static_module_t MODULES[] = {
 	{ C_MOD_SYNTH_RECORD, &synth_record_load, &synth_record_unload, MOD_SCOPE_ANY },
 	{ C_MOD_DNSPROXY,     &dnsproxy_load,     &dnsproxy_unload,     MOD_SCOPE_ANY },
 	{ C_MOD_ONLINE_SIGN,  &online_sign_load,  &online_sign_unload,  MOD_SCOPE_ZONE, true },
+	{ C_MOD_STATS,        &stats_load,        &stats_unload,        MOD_SCOPE_ANY, true },
 #ifdef HAVE_ROSEDB
 	{ C_MOD_ROSEDB,       &rosedb_load,       &rosedb_unload,       MOD_SCOPE_ANY },
 #endif
@@ -104,6 +108,67 @@ int query_plan_step(struct query_plan *plan, int stage, qmodule_process_t proces
 	return KNOT_EOK;
 }
 
+int mod_stats_add(struct query_module *module, const char *name, uint32_t count,
+                  mod_idx_to_str_f idx)
+{
+	if (module == NULL || count < 1) {
+		return KNOT_EINVAL;
+	}
+
+	mod_ctr_t *stats = NULL;
+	if (module->stats == NULL) {
+		assert(module->stats_count == 0);
+		stats = mm_alloc(module->mm, sizeof(*stats));
+		if (stats == NULL) {
+			return KNOT_ENOMEM;
+		}
+		module->stats = stats;
+	} else {
+		assert(module->stats_count > 0);
+		size_t old_size = module->stats_count * sizeof(*stats);
+		size_t new_size = old_size + sizeof(*stats);
+		stats = mm_realloc(module->mm, module->stats, new_size, old_size);
+		if (stats == NULL) {
+			mod_stats_free(module);
+			return KNOT_ENOMEM;
+		}
+		module->stats = stats;
+		stats += module->stats_count;
+	}
+
+	module->stats_count++;
+
+	if (count > 1) {
+		size_t size = count * sizeof(((mod_ctr_t *)0)->counter);
+		stats->counters = mm_alloc(module->mm, size);
+		if (stats->counters == NULL) {
+			mod_stats_free(module);
+			return KNOT_ENOMEM;
+		}
+		memset(stats->counters, 0, size);
+		stats->idx_to_str = idx;
+	}
+	stats->name = name;
+	stats->count = count;
+
+	return KNOT_EOK;
+}
+
+void mod_stats_free(struct query_module *module)
+{
+	if (module == NULL || module->stats == NULL) {
+		return;
+	}
+
+	for (int i = 0; i < module->stats_count; i++) {
+		if (module->stats[i].count > 1) {
+			mm_free(module->mm, module->stats[i].counters);
+		}
+	}
+
+	mm_free(module->mm, module->stats);
+}
+
 static_module_t *find_module(const yp_name_t *name)
 {
 	/* Search for the module by name. */
@@ -155,6 +220,7 @@ void query_module_close(struct query_module *module)
 		return;
 	}
 
+	mod_stats_free(module);
 	conf_free_mod_id(module->id);
 	mm_free(module->mm, module);
 }
diff --git a/src/knot/nameserver/query_module.h b/src/knot/nameserver/query_module.h
index 888b44387d2a90ce75f8d883028ff42248ba794f..2a006d6b6c45bacf313703d668c7c69ce9bd45c8 100644
--- a/src/knot/nameserver/query_module.h
+++ b/src/knot/nameserver/query_module.h
@@ -89,6 +89,20 @@ typedef struct static_module {
 	bool opt_conf;
 } static_module_t;
 
+typedef char* (*mod_idx_to_str_f)(uint32_t idx, uint32_t count);
+
+typedef struct {
+	const char *name;
+	union {
+		uint64_t counter;
+		struct {
+			uint64_t *counters;
+			mod_idx_to_str_f idx_to_str;
+		};
+	};
+	uint32_t count;
+} mod_ctr_t;
+
 /*!
  * Query module is a dynamically loadable unit that can alter query processing plan.
  * Module requires load and unload callback handlers and is provided with a context
@@ -102,9 +116,58 @@ struct query_module {
 	conf_mod_id_t *id;
 	qmodule_load_t load;
 	qmodule_unload_t unload;
+	mod_ctr_t *stats;
+	uint32_t stats_count;
 	unsigned scope;
 };
 
+int mod_stats_add(struct query_module *module, const char *name, uint32_t count,
+                  mod_idx_to_str_f idx);
+
+void mod_stats_free(struct query_module *module);
+
+inline static void mod_ctr_incr(mod_ctr_t *stats, uint32_t idx, uint64_t val)
+{
+	mod_ctr_t *ctr = stats + idx;
+	assert(ctr->count == 1);
+
+	__sync_fetch_and_add(&ctr->counter, val);
+}
+
+inline static void mod_ctr_decr(mod_ctr_t *stats, uint32_t idx, uint64_t val)
+{
+	mod_ctr_t *ctr = stats + idx;
+	assert(ctr->count == 1);
+
+	__sync_fetch_and_sub(&ctr->counter, val);
+}
+
+inline static void mod_ctrs_incr(mod_ctr_t *stats, uint32_t idx, uint32_t offset, uint64_t val)
+{
+	mod_ctr_t *ctr = stats + idx;
+	assert(ctr->count > 1);
+
+	// Increment the last counter if offset overflows.
+	if (offset < ctr->count) {
+		__sync_fetch_and_add(&ctr->counters[offset], val);
+	} else {
+		__sync_fetch_and_add(&ctr->counters[ctr->count - 1], val);
+	}
+}
+
+inline static void mod_ctrs_decr(mod_ctr_t *stats, uint32_t idx, uint32_t offset, uint64_t val)
+{
+	mod_ctr_t *ctr = stats + idx;
+	assert(ctr->count > 1);
+
+	// Increment the last counter if offset overflows.
+	if (offset < ctr->count) {
+		__sync_fetch_and_sub(&ctr->counters[offset], val);
+	} else {
+		__sync_fetch_and_sub(&ctr->counters[ctr->count - 1], val);
+	}
+}
+
 /*! \brief Single processing step in query processing. */
 struct query_step {
 	node_t node;
diff --git a/src/knot/server/server.c b/src/knot/server/server.c
index eb2e4d603a91a10a8b7a2cda6f5038cc1864b97e..c1bff958cdd6363c8704438353bf1e7cbe9e9e71 100644
--- a/src/knot/server/server.c
+++ b/src/knot/server/server.c
@@ -22,6 +22,7 @@
 
 #include "libknot/errcode.h"
 #include "knot/common/log.h"
+#include "knot/common/stats.h"
 #include "knot/conf/confio.h"
 #include "knot/server/server.h"
 #include "knot/server/udp-handler.h"
@@ -585,6 +586,7 @@ int server_reload(server_t *server)
 	}
 	if (full || (flags & CONF_IO_FRLD_SRV)) {
 		server_reconfigure(conf(), server);
+		stats_reconfigure(conf(), server);
 	}
 	if (full || (flags & (CONF_IO_FRLD_ZONES | CONF_IO_FRLD_ZONE))) {
 		server_update_zones(conf(), server);
diff --git a/src/utils/knotc/commands.c b/src/utils/knotc/commands.c
index 9636d0316f1eb4f616200ff166f9336e305e2668..afcd8fe1e48783e37c2dfb960d434ab1dbcddc86 100644
--- a/src/utils/knotc/commands.c
+++ b/src/utils/knotc/commands.c
@@ -36,6 +36,7 @@
 #define CMD_STATUS		"status"
 #define CMD_STOP		"stop"
 #define CMD_RELOAD		"reload"
+#define CMD_STATS		"stats"
 
 #define CMD_ZONE_CHECK		"zone-check"
 #define CMD_ZONE_MEMSTATS	"zone-memstats"
@@ -55,6 +56,7 @@
 #define CMD_ZONE_SET		"zone-set"
 #define CMD_ZONE_UNSET		"zone-unset"
 #define CMD_ZONE_PURGE		"zone-purge"
+#define CMD_ZONE_STATS		"zone-stats"
 
 #define CMD_CONF_INIT		"conf-init"
 #define CMD_CONF_CHECK		"conf-check"
@@ -270,26 +272,43 @@ static void format_data(ctl_cmd_t cmd, knot_ctl_type_t data_type,
 	case CTL_ZONE_GET:
 	case CTL_ZONE_SET:
 	case CTL_ZONE_UNSET:
-		if (data_type == KNOT_CTL_TYPE_DATA) {
-			printf("%s%s%s%s%s%s%s%s%s%s%s%s%s",
-			       (!(*empty)     ? "\n"       : ""),
-			       (error != NULL ? "error: (" : ""),
-			       (error != NULL ? error      : ""),
-			       (error != NULL ? ") "       : ""),
-			       (zone  != NULL ? "["        : ""),
-			       (zone  != NULL ? zone       : ""),
-			       (zone  != NULL ? "] "       : ""),
-			       (sign  != NULL ? sign       : ""),
-			       (owner != NULL ? owner      : ""),
-			       (ttl   != NULL ? " "        : ""),
-			       (ttl   != NULL ? ttl        : ""),
-			       (type  != NULL ? " "        : ""),
-			       (type  != NULL ? type       : ""));
-			*empty = false;
-		}
-		if (value != NULL) {
-			printf(" %s", value);
-		}
+		printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s",
+		       (!(*empty)     ? "\n"       : ""),
+		       (error != NULL ? "error: (" : ""),
+		       (error != NULL ? error      : ""),
+		       (error != NULL ? ") "       : ""),
+		       (zone  != NULL ? "["        : ""),
+		       (zone  != NULL ? zone       : ""),
+		       (zone  != NULL ? "] "       : ""),
+		       (sign  != NULL ? sign       : ""),
+		       (owner != NULL ? owner      : ""),
+		       (ttl   != NULL ? " "        : ""),
+		       (ttl   != NULL ? ttl        : ""),
+		       (type  != NULL ? " "        : ""),
+		       (type  != NULL ? type       : ""),
+		       (value != NULL ? " "        : ""),
+		       (value != NULL ? value      : ""));
+		*empty = false;
+		break;
+	case CTL_STATS:
+	case CTL_ZONE_STATS:
+		printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s",
+		       (!(*empty)     ? "\n"       : ""),
+		       (error != NULL ? "error: (" : ""),
+		       (error != NULL ? error      : ""),
+		       (error != NULL ? ") "       : ""),
+		       (zone  != NULL ? "["        : ""),
+		       (zone  != NULL ? zone       : ""),
+		       (zone  != NULL ? "] "       : ""),
+		       (key0  != NULL ? key0       : ""),
+		       (key1  != NULL ? "."        : ""),
+		       (key1  != NULL ? key1       : ""),
+		       (id    != NULL ? "["        : ""),
+		       (id    != NULL ? id         : ""),
+		       (id    != NULL ? "]"        : ""),
+		       (value != NULL ? " = "      : ""),
+		       (value != NULL ? value      : ""));
+		*empty = false;
 		break;
 	default:
 		assert(0);
@@ -334,6 +353,8 @@ static void format_block(ctl_cmd_t cmd, bool failed, bool empty)
 	case CTL_CONF_READ:
 	case CTL_CONF_DIFF:
 	case CTL_CONF_GET:
+	case CTL_ZONE_STATS:
+	case CTL_STATS:
 		printf("%s", empty ? "" : "\n");
 		break;
 	default:
@@ -409,6 +430,75 @@ static int cmd_ctl(cmd_args_t *args)
 	return ctl_receive(args);
 }
 
+static int set_stats_items(cmd_args_t *args, knot_ctl_data_t *data)
+{
+	int min_args, max_args;
+	switch (args->desc->cmd) {
+	case CTL_STATS:      min_args = 0; max_args = 1; break;
+	case CTL_ZONE_STATS: min_args = 1; max_args = 2; break;
+	default:
+		assert(0);
+		return KNOT_EINVAL;
+	}
+
+	// Check the number of arguments.
+	int ret = check_args(args, min_args, max_args);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	int idx = 0;
+
+	// Set ZONE name.
+	if (args->argc > idx && args->desc->cmd == CTL_ZONE_STATS) {
+		if (strcmp(args->argv[idx], "--") != 0) {
+			(*data)[KNOT_CTL_IDX_ZONE] = args->argv[idx];
+		}
+		idx++;
+	}
+
+	if (args->argc > idx) {
+		(*data)[KNOT_CTL_IDX_SECTION] = args->argv[idx];
+
+		char *item = strchr(args->argv[idx], '.');
+		if (item != NULL) {
+			// Separate section and item.
+			*item++ = '\0';
+			(*data)[KNOT_CTL_IDX_ITEM] = item;
+		}
+	}
+
+	return KNOT_EOK;
+}
+
+static int cmd_stats_ctl(cmd_args_t *args)
+{
+	knot_ctl_data_t data = {
+		[KNOT_CTL_IDX_CMD] = ctl_cmd_to_str(args->desc->cmd),
+		[KNOT_CTL_IDX_FLAGS] = args->force ? CTL_FLAG_FORCE : NULL
+	};
+
+	int ret = set_stats_items(args, &data);
+	if (ret != KNOT_EOK) {
+		return ret;
+	}
+
+	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_DATA, &data);
+	if (ret != KNOT_EOK) {
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
+		return ret;
+	}
+
+	// Finish the input block.
+	ret = knot_ctl_send(args->ctl, KNOT_CTL_TYPE_BLOCK, NULL);
+	if (ret != KNOT_EOK) {
+		log_error(CTL_LOG_STR" (%s)", knot_strerror(ret));
+		return ret;
+	}
+
+	return ctl_receive(args);
+}
+
 static int zone_exec(cmd_args_t *args, int (*fcn)(const knot_dname_t *, void *),
                      void *data)
 {
@@ -870,6 +960,7 @@ const cmd_desc_t cmd_table[] = {
 	{ CMD_STATUS,          cmd_ctl,           CTL_STATUS },
 	{ CMD_STOP,            cmd_ctl,           CTL_STOP },
 	{ CMD_RELOAD,          cmd_ctl,           CTL_RELOAD },
+	{ CMD_STATS,           cmd_stats_ctl,     CTL_STATS },
 
 	{ CMD_ZONE_CHECK,      cmd_zone_check,    CTL_NONE,            CMD_FOPT_ZONE | CMD_FREAD },
 	{ CMD_ZONE_MEMSTATS,   cmd_zone_memstats, CTL_NONE,            CMD_FOPT_ZONE | CMD_FREAD },
@@ -889,6 +980,7 @@ const cmd_desc_t cmd_table[] = {
 	{ CMD_ZONE_SET,        cmd_zone_node_ctl, CTL_ZONE_SET,        CMD_FREQ_ZONE },
 	{ CMD_ZONE_UNSET,      cmd_zone_node_ctl, CTL_ZONE_UNSET,      CMD_FREQ_ZONE },
 	{ CMD_ZONE_PURGE,      cmd_zone_ctl,      CTL_ZONE_PURGE,      CMD_FREQ_ZONE },
+	{ CMD_ZONE_STATS,      cmd_stats_ctl,     CTL_ZONE_STATS,      CMD_FREQ_ZONE },
 
 	{ CMD_CONF_INIT,       cmd_conf_init,     CTL_NONE,            CMD_FWRITE },
 	{ CMD_CONF_CHECK,      cmd_conf_check,    CTL_NONE,            CMD_FREAD },
@@ -912,6 +1004,7 @@ static const cmd_help_t cmd_help_table[] = {
 	{ CMD_STATUS,          "",                                       "Check if the server is running." },
 	{ CMD_STOP,            "",                                       "Stop the server if running." },
 	{ CMD_RELOAD,          "",                                       "Reload the server configuration and modified zones." },
+	{ CMD_STATS,           "[<module>[.<counter>]]",                 "Show global statistics counter(s)." },
 	{ "",                  "",                                       "" },
 	{ CMD_ZONE_CHECK,      "[<zone>...]",                            "Check if the zone can be loaded. (*)" },
 	{ CMD_ZONE_MEMSTATS,   "[<zone>...]",                            "Estimate memory use for the zone. (*)" },
@@ -931,6 +1024,7 @@ static const cmd_help_t cmd_help_table[] = {
 	{ CMD_ZONE_SET,        "<zone>  <owner> [<ttl>] <type> <rdata>", "Add zone record within the transaction." },
 	{ CMD_ZONE_UNSET,      "<zone>  <owner> [<type> [<rdata>]]",     "Remove zone data within the transaction." },
 	{ CMD_ZONE_PURGE,      "<zone>...",                              "Purge zone data, file, journal, and timers." },
+	{ CMD_ZONE_STATS,      "<zone> [<module>[.<counter>]]",          "Show zone statistics counter(s)."},
 	{ "",                  "",                                       "" },
 	{ CMD_CONF_INIT,       "",                                       "Initialize the confdb. (*)" },
 	{ CMD_CONF_CHECK,      "",                                       "Check the server configuration. (*)" },
diff --git a/src/utils/knotd/main.c b/src/utils/knotd/main.c
index 3a2be5ff81cd4c0a1d826c529234c54394ff7ef2..dd2bec909975e5e5735b408ed368dc47d9461b86 100644
--- a/src/utils/knotd/main.c
+++ b/src/utils/knotd/main.c
@@ -38,6 +38,7 @@
 #include "knot/conf/conf.h"
 #include "knot/common/log.h"
 #include "knot/common/process.h"
+#include "knot/common/stats.h"
 #include "knot/server/server.h"
 #include "knot/server/tcp-handler.h"
 #include "knot/zone/timers.h"
@@ -525,6 +526,8 @@ int main(int argc, char **argv)
 		log_warning("no zones loaded");
 	}
 
+	stats_reconfigure(conf(), &server);
+
 	/* Start it up. */
 	log_info("starting server");
 	conf_val_t async_val = conf_get(conf(), C_SRV, C_ASYNC_START);
@@ -532,6 +535,7 @@ int main(int argc, char **argv)
 	if (ret != KNOT_EOK) {
 		log_fatal("failed to start server (%s)", knot_strerror(ret));
 		server_wait(&server);
+		stats_deinit();
 		server_deinit(&server);
 		rcu_unregister_thread();
 		pid_cleanup();
@@ -553,6 +557,7 @@ int main(int argc, char **argv)
 	/* Teardown server. */
 	server_stop(&server);
 	server_wait(&server);
+	stats_deinit();
 
 	log_info("updating zone timers database");
 	write_timer_db(server.timers_db, server.zone_db);