diff --git a/Knot.files b/Knot.files
index fc87d0da5fb02f93d1ca122cfd368e0849c6e23b..f6cf5bfb22f1952fcb7871e571bf1be9dc204b6d 100644
--- a/Knot.files
+++ b/Knot.files
@@ -524,6 +524,8 @@ src/utils/knotd/main.c
 src/utils/knsec3hash/knsec3hash.c
 src/utils/knsupdate/knsupdate_exec.c
 src/utils/knsupdate/knsupdate_exec.h
+src/utils/knsupdate/knsupdate_interactive.c
+src/utils/knsupdate/knsupdate_interactive.h
 src/utils/knsupdate/knsupdate_main.c
 src/utils/knsupdate/knsupdate_params.c
 src/utils/knsupdate/knsupdate_params.h
diff --git a/src/utils/Makefile.inc b/src/utils/Makefile.inc
index 806dab5c1199d9065f5938801e505db771adb3ad..c36083e3ef7fdf2322409efc5702aa02ca748675 100644
--- a/src/utils/Makefile.inc
+++ b/src/utils/Makefile.inc
@@ -64,10 +64,12 @@ knsec3hash_SOURCES = \
 	utils/knsec3hash/knsec3hash.c
 
 knsupdate_SOURCES = \
-	utils/knsupdate/knsupdate_exec.c	\
-	utils/knsupdate/knsupdate_exec.h	\
-	utils/knsupdate/knsupdate_main.c	\
-	utils/knsupdate/knsupdate_params.c	\
+	utils/knsupdate/knsupdate_exec.c		\
+	utils/knsupdate/knsupdate_exec.h		\
+	utils/knsupdate/knsupdate_interactive.c		\
+	utils/knsupdate/knsupdate_interactive.h		\
+	utils/knsupdate/knsupdate_main.c		\
+	utils/knsupdate/knsupdate_params.c		\
 	utils/knsupdate/knsupdate_params.h
 
 kdig_CPPFLAGS          = $(libknotus_la_CPPFLAGS)
diff --git a/src/utils/knsupdate/knsupdate_exec.c b/src/utils/knsupdate/knsupdate_exec.c
index 6a8108ddffe50acb09c3c53c7c8ab7bea7479bf1..9ffd882254a8710b9b76655c3bc7100badd6ffa7 100644
--- a/src/utils/knsupdate/knsupdate_exec.c
+++ b/src/utils/knsupdate/knsupdate_exec.c
@@ -23,6 +23,7 @@
 
 #include "libdnssec/random.h"
 #include "utils/knsupdate/knsupdate_exec.h"
+#include "utils/knsupdate/knsupdate_interactive.h"
 #include "utils/common/exec.h"
 #include "utils/common/msg.h"
 #include "utils/common/netio.h"
@@ -66,7 +67,7 @@ int cmd_zone(const char* lp, knsupdate_params_t *params);
  * This way we could identify command byte-per-byte and
  * cancel early if the next is lexicographically greater.
  */
-const char* cmd_array[] = {
+const char* knsupdate_cmd_array[] = {
 	"\x3" "add",
 	"\x6" "answer",
 	"\x5" "class",         /* {classname} */
@@ -474,26 +475,24 @@ static int pkt_sendrecv(knsupdate_params_t *params)
 	return rb;
 }
 
-static int process_line(char *lp, void *arg)
+int knsupdate_process_line(const char *line, knsupdate_params_t *params)
 {
-	knsupdate_params_t *params = (knsupdate_params_t *)arg;
-
 	/* Check for empty line or comment. */
-	if (lp[0] == '\0' || lp[0] == ';') {
+	if (line[0] == '\0' || line[0] == ';') {
 		return KNOT_EOK;
 	}
 
-	int ret = tok_find(lp, cmd_array);
+	int ret = tok_find(line, knsupdate_cmd_array);
 	if (ret < 0) {
 		return ret; /* Syntax error - do nothing. */
 	}
 
-	const char *cmd = cmd_array[ret];
-	const char *val = tok_skipspace(lp + TOK_L(cmd));
+	const char *cmd = knsupdate_cmd_array[ret];
+	const char *val = tok_skipspace(line + TOK_L(cmd));
 	ret = cmd_handle[ret](val, params);
 	if (ret != KNOT_EOK) {
 		DBG("operation '%s' failed (%s) on line '%s'\n",
-		    TOK_S(cmd), knot_strerror(ret), lp);
+		    TOK_S(cmd), knot_strerror(ret), line);
 	}
 
 	return ret;
@@ -510,44 +509,23 @@ static int process_lines(knsupdate_params_t *params, FILE *input)
 {
 	char *buf = NULL;
 	size_t buflen = 0;
-	bool interactive = is_terminal(input);
-	int ret = KNOT_EOK;
-
-	/* Print first program prompt if interactive. */
-	if (interactive) {
-		fprintf(stderr, "> ");
+	if(is_terminal(input)) {
+		return interactive_loop(params);
 	}
+	int ret = KNOT_EOK;
 
 	/* Process lines. */
 	while (!params->stop && knot_getline(&buf, &buflen, input) != -1) {
 		/* Remove leading and trailing white space. */
 		char *line = strstrip(buf);
-		int call_ret = process_line(line, params);
+		ret = knsupdate_process_line(line, params);
 		memset(line, 0, strlen(line));
 		free(line);
-		if (call_ret != KNOT_EOK) {
-			/* Return the first error. */
-			if (ret == KNOT_EOK) {
-				ret = call_ret;
-			}
-
-			/* Exit if error and not interactive. */
-			if (!interactive) {
-				break;
-			}
-		}
-
-		/* Print program prompt if interactive. */
-		if (interactive && !params->stop) {
-			fprintf(stderr, "> ");
+		if (ret != KNOT_EOK) {
+			break;
 		}
 	}
 
-	if (interactive && feof(input)) {
-		/* Terminate line after empty prompt. */
-		fprintf(stderr, "\n");
-	}
-
 	if (buf != NULL) {
 		memset(buf, 0, buflen);
 		free(buf);
@@ -564,9 +542,9 @@ int knsupdate_exec(knsupdate_params_t *params)
 
 	int ret = KNOT_EOK;
 
-	/* If no file specified, use stdin. */
+	/* If no file specified, enter the interactive mode. */
 	if (EMPTY_LIST(params->qfiles)) {
-		ret = process_lines(params, stdin);
+		ret = interactive_loop(params);
 	}
 
 	/* Read from each specified file. */
@@ -603,7 +581,7 @@ int cmd_update(const char* lp, knsupdate_params_t *params)
 	DBG("%s: lp='%s'\n", __func__, lp);
 
 	/* update is optional token, next add|del|delete */
-	int bp = tok_find(lp, cmd_array);
+	int bp = tok_find(lp, knsupdate_cmd_array);
 	if (bp < 0) return bp; /* Syntax error. */
 
 	/* allow only specific tokens */
@@ -614,7 +592,7 @@ int cmd_update(const char* lp, knsupdate_params_t *params)
 		return KNOT_EPARSEFAIL;
 	}
 
-	return h[bp](tok_skipspace(lp + TOK_L(cmd_array[bp])), params);
+	return h[bp](tok_skipspace(lp + TOK_L(knsupdate_cmd_array[bp])), params);
 }
 
 int cmd_add(const char* lp, knsupdate_params_t *params)
diff --git a/src/utils/knsupdate/knsupdate_exec.h b/src/utils/knsupdate/knsupdate_exec.h
index 1cf870833888c8e382cc1e82fcadb4261a9a1376..36ca43b2df5a9113e8e0d70694d3cc3b8eb677f3 100644
--- a/src/utils/knsupdate/knsupdate_exec.h
+++ b/src/utils/knsupdate/knsupdate_exec.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2018 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -18,4 +18,8 @@
 
 #include "utils/knsupdate/knsupdate_params.h"
 
+extern const char* knsupdate_cmd_array[];
+
 int knsupdate_exec(knsupdate_params_t *params);
+
+int knsupdate_process_line(const char *line, knsupdate_params_t *params);
diff --git a/src/utils/knsupdate/knsupdate_interactive.c b/src/utils/knsupdate/knsupdate_interactive.c
new file mode 100644
index 0000000000000000000000000000000000000000..898496415113d9f26629b346af0c4b6292643fd9
--- /dev/null
+++ b/src/utils/knsupdate/knsupdate_interactive.c
@@ -0,0 +1,176 @@
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    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 <histedit.h>
+
+#include "contrib/string.h"
+#include "utils/common/lookup.h"
+#include "utils/common/msg.h"
+#include "utils/knsupdate/knsupdate_exec.h"
+#include "utils/knsupdate/knsupdate_interactive.h"
+
+#define PROGRAM_NAME	"knsupdate"
+#define HISTORY_FILE	".knsupdate_history"
+
+static char *prompt(EditLine *el)
+{
+	return PROGRAM_NAME"> ";
+}
+
+static void print_commands(void)
+{
+	for (const char **cmd = knsupdate_cmd_array; *cmd != NULL; cmd++) {
+		printf(" %-18s\n", (*cmd) + 1);
+	}
+}
+
+static void cmds_lookup(EditLine *el, const char *str, size_t str_len)
+{
+	lookup_t lookup;
+	int ret = lookup_init(&lookup);
+	if (ret != KNOT_EOK) {
+		return;
+	}
+
+	// Fill the lookup with command names.
+	for (const char **desc = knsupdate_cmd_array; *desc != NULL; desc++) {
+		ret = lookup_insert(&lookup, (*desc) + 1, NULL);
+		if (ret != KNOT_EOK) {
+			goto cmds_lookup_finish;
+		}
+	}
+
+	lookup_complete(&lookup, str, str_len, el, true);
+
+cmds_lookup_finish:
+	lookup_deinit(&lookup);
+}
+
+static unsigned char complete(EditLine *el, int ch)
+{
+	int argc, token, pos;
+	const char **argv;
+
+	const LineInfo *li = el_line(el);
+	Tokenizer *tok = tok_init(NULL);
+
+	// Parse the line.
+	int ret = tok_line(tok, li, &argc, &argv, &token, &pos);
+	if (ret != 0) {
+		goto complete_exit;
+	}
+
+	// Show possible commands.
+	if (argc == 0) {
+		print_commands();
+		goto complete_exit;
+	}
+
+	// Complete the command name.
+	if (token == 0) {
+		cmds_lookup(el, argv[0], pos);
+		goto complete_exit;
+	}
+
+	// Find the command descriptor.
+	const char **desc = knsupdate_cmd_array;
+	while (*desc != NULL && strcmp((*desc) + 1, argv[0]) != 0) {
+		(*desc)++;
+	}
+	if (*desc == NULL) {
+		goto complete_exit;
+	}
+
+complete_exit:
+	tok_reset(tok);
+	tok_end(tok);
+
+	return CC_REDISPLAY;
+}
+
+int interactive_loop(knsupdate_params_t *params)
+{
+	char *hist_file = NULL;
+	const char *home = getenv("HOME");
+	if (home != NULL) {
+		hist_file = sprintf_alloc("%s/%s", home, HISTORY_FILE);
+	}
+	if (hist_file == NULL) {
+		INFO("failed to get home directory");
+	}
+
+	EditLine *el = el_init(PROGRAM_NAME, stdin, stdout, stderr);
+	if (el == NULL) {
+		ERR("interactive mode not available");
+		free(hist_file);
+		return KNOT_ERROR;
+	}
+
+	History *hist = history_init();
+	if (hist == NULL) {
+		ERR("interactive mode not available");
+		el_end(el);
+		free(hist_file);
+		return KNOT_ERROR;
+	}
+
+	HistEvent hev = { 0 };
+	history(hist, &hev, H_SETSIZE, 1000);
+	history(hist, &hev, H_SETUNIQUE, 1);
+	el_set(el, EL_HIST, history, hist);
+	history(hist, &hev, H_LOAD, hist_file);
+
+	el_set(el, EL_TERMINAL, NULL);
+	el_set(el, EL_EDITOR, "emacs");
+	el_set(el, EL_PROMPT, prompt);
+	el_set(el, EL_SIGNAL, 1);
+	el_source(el, NULL);
+
+	// Warning: these two el_sets()'s always leak -- in libedit2 library!
+	// For more details see this commit's message.
+	el_set(el, EL_ADDFN, PROGRAM_NAME"-complete",
+	       "Perform "PROGRAM_NAME" completion.", complete);
+	el_set(el, EL_BIND, "^I",  PROGRAM_NAME"-complete", NULL);
+
+	int count;
+	const char *line;
+	while ((line = el_gets(el, &count)) != NULL && count > 0) {
+		char command[count + 1];
+		memcpy(command, line, count);
+		command[count] = '\0';
+		// Removes trailing newline
+		size_t cmd_len = strcspn(command, "\n");
+		command[cmd_len] = '\0';
+
+		if (cmd_len > 0) {
+			history(hist, &hev, H_ENTER, command);
+			history(hist, &hev, H_SAVE, hist_file);
+		}
+
+		// Process the command.
+		(void)knsupdate_process_line(command, params);
+		if (params->stop) {
+			break;
+		}
+	}
+
+	history_end(hist);
+	free(hist_file);
+
+	el_end(el);
+
+	return KNOT_EOK;
+}
diff --git a/src/utils/knsupdate/knsupdate_interactive.h b/src/utils/knsupdate/knsupdate_interactive.h
new file mode 100644
index 0000000000000000000000000000000000000000..e2a24282ed0268c34ffc4d45dfbef8c4fa9ecf35
--- /dev/null
+++ b/src/utils/knsupdate/knsupdate_interactive.h
@@ -0,0 +1,26 @@
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    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 "utils/knsupdate/knsupdate_params.h"
+
+/*!
+ * Executes an interactive processing loop.
+ *
+ * \param[in] params  Utility parameters.
+ */
+int interactive_loop(knsupdate_params_t *params);