diff --git a/xapian-core/queryparser/termgenerator_internal.cc b/xapian-core/queryparser/termgenerator_internal.cc
index 7fa807db6064..9e50a77f85cf 100644
--- a/xapian-core/queryparser/termgenerator_internal.cc
+++ b/xapian-core/queryparser/termgenerator_internal.cc
@@ -432,6 +432,27 @@ SnipPipe::done()
     }
 }
 
+inline void
+append_escaping_xml(const char* p, const char* end, string& output)
+{
+    while (p != end) {
+	char ch = *p++;
+	switch (ch) {
+	    case '&':
+		output += "&amp;";
+		break;
+	    case '<':
+		output += "&lt;";
+		break;
+	    case '>':
+		output += "&gt;";
+		break;
+	    default:
+		output += ch;
+	}
+    }
+}
+
 inline bool
 SnipPipe::drain(const string & input,
 		const string & hi_start,
@@ -465,7 +486,7 @@ SnipPipe::drain(const string & input,
 
 	if (punc) {
 	    // Include end of sentence punctuation.
-	    output.append(input.data() + best_end, i.raw());
+	    append_escaping_xml(input.data() + best_end, i.raw(), output);
 	} else {
 	    // Append "..." or equivalent if this doesn't seem to be the start
 	    // of a sentence.
@@ -523,8 +544,7 @@ SnipPipe::drain(const string & input,
 	while (i != Utf8Iterator()) {
 	    unsigned ch = *i;
 	    if (Unicode::is_wordchar(ch)) {
-		const char * p = input.data() + best_begin;
-		output.append(p, i.raw() - p);
+		append_escaping_xml(input.data() + best_begin, i.raw(), output);
 		best_begin = i.raw() - input.data();
 		break;
 	    }
@@ -537,22 +557,9 @@ SnipPipe::drain(const string & input,
 	if (phrase_len) output += hi_start;
     }
 
-    while (best_begin != word.term_end) {
-	char ch = input[best_begin++];
-	switch (ch) {
-	    case '&':
-		output += "&amp;";
-		break;
-	    case '<':
-		output += "&lt;";
-		break;
-	    case '>':
-		output += "&gt;";
-		break;
-	    default:
-		output += ch;
-	}
-    }
+    const char* p = input.data();
+    append_escaping_xml(p + best_begin, p + word.term_end, output);
+    best_begin = word.term_end;
 
     if (phrase_len && --phrase_len == 0) output += hi_end;
 
@@ -640,8 +647,11 @@ MSet::Internal::snippet(const string & text,
 			const string & omit) const
 {
     if (hi_start.empty() && hi_end.empty() && text.size() <= length) {
-	// Too easy!
-	return text;
+	// The text is already short enough so we just need to perform
+	// escaping.
+	string output;
+	append_escaping_xml(text.data(), text.data() + text.size(), output);
+	return output;
     }
 
     bool cjk_ngram = CJK::is_cjk_enabled();
diff --git a/xapian-core/tests/api_snippets.cc b/xapian-core/tests/api_snippets.cc
index 4c9296f88d84..6cac1af46406 100644
--- a/xapian-core/tests/api_snippets.cc
+++ b/xapian-core/tests/api_snippets.cc
@@ -313,3 +313,33 @@ DEFINE_TESTCASE(snippet_empty, backend) {
 
     return true;
 }
+
+/// Check snippets escape HTML/XML suitably.
+DEFINE_TESTCASE(snippet_html_escape, backend) {
+    Xapian::Enquire enquire(get_database("apitest_simpledata"));
+    enquire.set_query(Xapian::Query("foo"));
+
+    Xapian::MSet mset = enquire.get_mset(0, 0);
+
+    Xapian::Stem stem;
+
+    const char *input = "#include <foo.h> to use libfoo";
+    TEST_STRINGS_EQUAL(mset.snippet(input, 12, stem),
+		       "...&lt;<b>foo</b>.h&gt; to...");
+
+    input = "&foo takes the address of foo";
+    TEST_STRINGS_EQUAL(mset.snippet(input, strlen(input), stem),
+		       "&amp;<b>foo</b> takes the address of <b>foo</b>");
+
+    // Check escaping still happens without highlighting when the text is
+    // already short enough.  Regression test for bug fixed in 1.4.32 and
+    // 2.0.1.
+    input = "&foo takes the address of foo";
+    TEST_STRINGS_EQUAL(mset.snippet(input, strlen(input), stem, 0, "", ""),
+		       "&amp;foo takes the address of foo");
+    input = "<foo> &amp;";
+    TEST_STRINGS_EQUAL(mset.snippet(input, 12, stem, 0, "", ""),
+		       "&lt;foo&gt; &amp;amp;");
+
+    return true;
+}
