Skip to content
GDPR compliant. Hosted in DE.
14-day free trial →
MonitoringCharacter encodingMigration
Monitoring 13 min read 25 Sept 2026

Broken Characters After Migration: Check Your Encoding

After a server move, a database migration or a system change, pages suddenly show Grüße instead of Grüße. Where the encoding is decided, which declaration takes precedence and how to measure the state after the switch.

X

XICTRAQ

XICTRAQ

MonitoringSEOPerformance
1024 bytes
the range within which an HTML document's encoding declaration must appear in full (HTML Living Standard)
1 to 4 bytes
are used per character in UTF-8, depending on the code point (RFC 3629)
3 bytes
maximum per character in the utf8mb3 character set, four only in utf8mb4 (MariaDB)
The character encoding chainFive places decide how a special character reaches the reader1 Storageutf8mb42 ConnectionSET NAMES3 HTTP headercharset=utf-84 Documentmeta charset5 BrowserRenderingPrecedence when declarations disagree1. Byte order mark2. HTTP Content-Type3. meta charset4. Fallback windows-1252How the same text reaches the readerStored in the databaseServed without a declarationServed with charset=utf-8ZürichZürichZürichCaféCaféCafé39.90 €39.90 €39.90 €Precedence and the 1024-byte limit: HTML Living Standard, sections 4.2.5.4 and 13.2.3Declaration must appear within1024 bytes (HTML Living Standard)UTF-8 per character1 to 4 octets (RFC 3629)Character set utf8mb3at most 3 bytes (MariaDB)
A server move, a database migration or a change of content management system rarely alters the layout, but it often alters the character encoding. The result shows up as “Grüße” or “Café” in product copy, form labels and file names, while the home page and the menu still look clean. The availability check keeps reporting status code 200, load time is unchanged, a security scan finds nothing. The damage is commercial all the same: unreadable prices, searches that return nothing, form data filed under the wrong name. This article describes where a character encoding is actually decided, which declaration wins when two disagree, what the visible pattern says about the cause, and how to measure the state after a migration instead of guessing it.

Why broken characters only surface after the migration

A file does not contain text, it contains a sequence of bytes. Which characters come out of it is decided by the encoding someone reads those bytes with. UTF-8 encodes characters from the U+0000 to U+10FFFF range in sequences of one to four octets (RFC 3629); a “ü” (U+00FC) takes two bytes, a “€” (U+20AC) takes three. Read the same bytes with a single-byte character set and each of those bytes becomes a character of its own, so one “ü” turns into two visible characters. That is exactly what broken characters are. The bytes themselves are usually untouched; what is wrong is the instruction for how to read them.

Before the migration that instruction was mostly correct without anyone having stated it. The old web server set a default, the old database set a matching one, and the result looked right. A migration replaces precisely those tacit defaults: a different setting in the web server, a different default character set in the database, an export filter that makes a different assumption. That is why the break rarely appears everywhere at once. What comes from the database is hit first, while statically stored menu labels stay untouched. The home page therefore often looks fine even when several hundred detail pages do not. What else drifts measurably during a migration is covered in the article on monitoring a website migration.

  • 1 to 4 octets are used per character from the U+0000 to U+10FFFF range in UTF-8 (RFC 3629).
  • 1024 bytes is the limit within which an HTML document's encoding declaration must appear in full (HTML Living Standard).
  • Exactly one meta-based encoding declaration per document is permitted (HTML Living Standard).
  • windows-1252 is what the standard suggests as the fallback for every locale not named separately in its table; the normative fallback itself stays implementation-defined (HTML Living Standard).
  • U+FFFD is the replacement character a decoder emits for invalid byte sequences in the “replacement” error mode (Encoding Standard).
  • One to three bytes per character are stored by the utf8mb3 character set, with four bytes for supplementary characters only in utf8mb4 (MariaDB documentation).

These values come from standards texts and from the documentation of an open-source database, not from a survey. They therefore say nothing about how often a fault occurs, only what is supposed to hold and what limits the parts involved have. For fault-finding that is the more useful kind of number, because it can be checked against any individual case. How many pages in your own site are affected is a question only a measurement across the whole site can answer, and that is what this article is really about.

The chain: five places where an encoding is decided

Between the stored byte and the displayed character there are several places where an encoding is declared or assumed. First, storage: character set and collation of the database column. Second, the connection between application and database, which negotiates an encoding of its own. Third, the application itself, including the encoding of its template files. Fourth, transport, meaning the Content-Type header field of the HTTP response. Fifth, the document with its own declaration. If all five agree, the case is boring. A migration typically changes two or three of them, and what remains is a chain in which one link claims something different from the rest.

The header field of the HTTP response is the place that looks different after a server change particularly often. RFC 9110 states that a sender generating a message containing content should generate a Content-Type header field unless the intended media type is unknown to it (RFC 9110, section 8.3); the same section notes that in practice origin servers are not consistently configured to deliver the correct Content-Type (RFC 9110). A Content-Type without a charset parameter is permitted and consequential all the same, because the decision then drops one level. A Content-Type with the wrong charset parameter is the more awkward case, because it beats the declaration inside the document.

An encoding declaration converts nothing

The declaration in the header field or in the document describes how the delivered bytes are to be read. It does not change the bytes. Switching the declaration from utf-8 to a single-byte character set so that the page looks right again does not fix the fault, it freezes it: the stored data stays as it is, and every downstream step — search, export, interface, invoice document — keeps working from the wrong assumption.

Precedence: which declaration wins when two disagree

The HTML Living Standard describes the order in which a document's encoding is determined as a procedure of its own. For fault-finding what matters most is that the order is fixed and that earlier steps override later ones.

  1. Byte order mark: if the byte stream starts with a BOM, the supplied encoding argument is ignored and the BOM itself is skipped (HTML Living Standard). A BOM in a program file also produces output before the first header field, which is a second, unrelated fault.
  2. User override: if someone has forced an encoding in the browser, the user agent may return that encoding; in the standard this step follows the BOM check and is worded as optional (HTML Living Standard). It explains why a page looks right on one workstation and wrong on every other.
  3. Transport layer: if the transport layer specifies a supported encoding, that encoding is returned with confidence “certain” (HTML Living Standard). This is the charset parameter in Content-Type.
  4. Document prescan: only then is the document itself examined. Authoring conformance requirements limit encoding declarations to the first 1024 bytes, and browsers are expected to prescan exactly that range (HTML Living Standard).
  5. Fallback: if everything is still open, an implementation-defined default encoding applies that depends on the locale; for every locale not named separately the table gives windows-1252 (HTML Living Standard).

This order produces the finding that occurs particularly often after a server move: the document carries the right declaration, the header field carries the wrong one, and the rendering follows the header field. The declaration inside the document is therefore no protection against a wrong declaration in the header, only the safety net for the case where no charset is sent at all. Conversely, exceeding the limit of 1024 bytes — because a long comment, a consent layer or an embedded script sits before the head section — loses the declaration inside the document silently. The standard also permits exactly one meta-based declaration per document (HTML Living Standard); two contradictory declarations are not a safety net but a fault.

The header beats the document

Before any change to the document, measure the header field. A Content-Type: text/html without charset behaves differently from Content-Type: text/html; charset=iso-8859-1, and both look equally wrong in the browser window. The difference decides whether the repair belongs in the server configuration or in the template. So measure the header first, then the first 1024 bytes, then the stored data — in that order.

Patterns: what the broken text says about the cause

Broken characters come in a small number of easily distinguished forms. Reading the pattern correctly saves the detour through guesswork. The decisive question is whether the damage is only in the rendering or already in the stored byte.

Visible patternCauseWhere the repair belongs
Grüße on the pageUTF-8 bytes are read as a single-byte character setalign header field and document declaration
Grüße in the browser, Grüße in the exportrendering is fine, the stored data is double-encodedthe stored data, not the rendering
Replacement characters inside a wordinvalid byte sequence, U+FFFD substitutedrestore the state before the re-encoding run
Grüßethe text was re-encoded to UTF-8 twicerepeat the migration step instead of patching it
Digit sequences with a hash sign in the stored textcharacter references from a form were stored as textthe input path and the form encoding

Two of these rows deserve an explanation. The third is the only case in which the information is genuinely gone: the Encoding Standard prescribes that in the “replacement” error mode a decoder emits U+FFFD for an invalid byte sequence (Encoding Standard). The original byte generally cannot be recomputed from the result, because different faulty sequences produce the same replacement character. The fourth row is the case a second repair makes worse: re-encoding already double-encoded text creates a third layer. The way back here runs through the state before the migration, not through another pass.

What a post-migration check actually has to measure

A status light does not answer the encoding question. Status code 200, unchanged load time and a valid certificate are perfectly compatible with completely unreadable product copy; uptime monitoring is the baseline, not the answer. What has to be measured is the content itself, across the whole site rather than three spot checks, because the fault sits precisely where nobody looks often.

Text comparison version by version

The screenshot and change monitoring keeps the previous text state for each page and reports when the character pattern changes. A break that hits many pages at once stands out in this view as a pattern rather than as a one-off.

Titles and descriptions in view

SEO monitoring checks titles, descriptions and structured data. That is where broken characters first become visible to visitors, because search results and shared previews are built from those fields.

Input paths in a real browser

Browser monitoring walks through form, search and cart. Faults that only appear when a field with umlauts is submitted show up on this path and not in a request for the home page.

An alert someone actually accepts

Alerting distributes the finding across two channels and escalates when nobody responds. An encoding fault is rarely urgent in the sense of an outage, but expensive if it sits unnoticed for three weeks.

What also belongs in this picture is what changed between two states at all. A visual comparison shows the break where it is noticeable in the layout; how that method works and where its limits are is described in the article on detecting website changes by visual comparison. For the text side the article on detecting content changes applies, because it asks the same question from the direction of unauthorised edits.

Measuring it yourself: from the header field to the byte

The measurement needs no special equipment. Three requests narrow the fault down to one layer, and they do so in the same order a browser uses to decide.

$ curl -sSI https://www.example.invalid/products/greetings | grep -i '^content-type'
content-type: text/html
$ curl -sS https://www.example.invalid/products/greetings | head -c 1024 | grep -io 'charset=[a-z0-9-]*'
charset=utf-8
$ curl -sS https://www.example.invalid/products/greetings | grep -c 'Ã'
23

The first request shows the situation that often follows a server change: a Content-Type without a charset parameter. The decision then drops one level, and the second request answers whether anything is declared there at all and whether it sits within the first 1024 bytes, because only that range counts (HTML Living Standard). The third request counts a character that hardly ever occurs in ordinary German or English prose and that sits inside almost every umlaut in mis-decoded UTF-8. A count well above zero is a strong indication; zero is not an acquittal, because stored data can also contain replacement characters.

pattern.py
# Classifies a delivered byte sequence: clean, mis-decoded or lost
TRACES = ("Ã", "Â", "â€", "")

def classify(raw: bytes) -> str:
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError as error:
        return f"not a valid UTF-8 sequence from byte {error.start}"

    if "�" in text:
        return "replacement characters present: the loss is already in the data"

    if any(trace in text for trace in TRACES):
        # reverse via windows-1252: works only if the text was mis-decoded exactly once
        original = text.encode("cp1252", errors="strict").decode("utf-8")
        return "mis-decoded once, reversible: " + original

    return "unremarkable"


print(classify("Grüße".encode("utf-8")))
print(classify("Grüße".encode("utf-8").decode("cp1252").encode("utf-8")))

The script separates the three cases that can be told apart from the result: a byte sequence that is not valid UTF-8 at all, a text containing replacement characters, and a text whose way back through windows-1252 succeeds. The last branch is the useful one, because it supplies the proof along with the verdict: if the reverse calculation yields readable text again, the stored data was fine and only the reading instruction was wrong. If the reverse calculation fails, the text contains characters that do not exist in windows-1252 and the cause lies elsewhere.

An encoding fault does not announce itself. It waits until somebody searches for an order with an umlaut in the name and finds none.

— XICTRAQ

The stored data is the second scene

If the rendering is set up correctly and the text stays broken, the fault is in the stored data. There it has two common causes: the character set of the column and the encoding of the connection the application reads and writes through. Both are easily overlooked during a migration, because an export followed by an import can silently reinterpret either side. On top of that comes a limit that is documented and still regularly surprises people: the utf8mb3 character set stores one to three bytes per character and holds no supplementary characters, whereas utf8mb4 uses the same encoding with four bytes for supplementary characters (MariaDB documentation). Characters outside the basic plane, such as symbols and rare scripts, therefore fit into one column and not into the other.

  • Check character set and collation per table and per column, not just the database default; individual columns often still carry the state from before the last conversion.
  • Check the encoding of the connection the application reads and writes through; it is independent of the column's character set and tends to be reset when the runtime environment changes.
  • Check the encoding of template and translation files; a single file in the old character set produces a fault that appears on only a few pages and is therefore overlooked for a long time.
  • Read backups and exports back: a backup that already contains mis-decoded text makes the way back worthless.
  • Check output paths separately — page, interface, invoice document, export file; each path makes its own assumption.
  • Take a backup before any re-encoding run and measure how long the way back takes instead of estimating it.
A re-encoding run without a way back is a one-way step

As long as only the rendering is wrong, the bytes are complete and the fault is reversible. Once a re-encoding run has passed over the stored data and replaced invalid sequences with the replacement character, the original information is generally gone (Encoding Standard). So take a backup before every run, check it for readability, and let the run go against a copy first. A run that rewrites the whole dataset in a minute needs the same care on the way back as on the way there.

Visibly broken characters are the harmless part. What happens when a form is submitted is worse. Forms send their values in the document's encoding, and for the case where a character cannot be represented in that encoding the Encoding Standard defines an error mode of its own. The standard states explicitly that this mode causes a sequence to be emitted that cannot be distinguished from legitimate input and can therefore lead to silent data loss (Encoding Standard). In practice that means a surname with an umlaut is turned into a digit sequence, the application stores it as text, and nobody notices until somebody searches for that name.

The same chain affects three further places that are rarely checked along with it. First, search: an index that took in text in one encoding and receives queries in another returns zero results without reporting an error. Second, sorting: collation rules decide whether “Öl” comes before or after “Ozean”, and a conversion changes the order of lists somebody assumed to be stable. Third, file names transmitted in header fields; the name of a downloaded invoice document is a common place for broken characters, because it takes a different route than the page content. If a confirmation email belongs to the same process, the article on monitoring the double opt-in flow applies as well, because the same chain runs there over one more delivery path.

A character lost in a form does not come back

Walk through the input path once in full with a test record containing umlauts, an eszett, a euro sign and a typographic quotation mark: submit the form, look at the record in the database, read the confirmation email, search for the record in the admin view. Four stations, four opportunities for a silent loss. The pass takes a few minutes and typically uncovers at least one place where the chain is not continuous.

The sequence before and after a migration

The preceding sections turn into an order of work. It assumes that the state before the migration is recorded, because without a reference state it is no longer decidable after the switch whether a character was already wrong beforehand.

  1. Before the migration, take stock: note the encoding per layer — column, connection, template files, header field, document declaration. Five lines per application, in writing.
  2. Before the migration, reference sample: record a fixed list of pages that contain umlauts, an eszett, a euro sign, typographic quotation marks and at least one character outside the basic plane. That list becomes the yardstick later.
  3. During the transition, measure the preview: check the new state under a preview address first and hold header field, first 1024 bytes and stored data there against the reference sample.
  4. After the switch, header and document: measure the header field and the document declaration before anything in the content is changed. That measurement decides whether the repair belongs in the configuration or in the data.
  5. After the switch, the data: run the text comparison across all pages and group the hits by pattern; a break that affects exactly one template looks different from one that affects half the database.
  6. After the switch, input paths: re-check form flow, search, sorting and file names with the same test record.
  7. Permanently: keep the text comparison and the check of the encoding declaration running in normal operation instead of switching them off after two weeks.
The one sample that carries the migration

If only one item from this sequence gets done, make it the reference sample from step two. It costs half an hour, it is re-run within a few minutes after the switch, and it answers the question that otherwise stays open for days: was this character right before? Without a reference state that question typically ends in a discussion rather than in a measurement.

Encoding belongs in continuous monitoring, not on the migration checklist

A migration is the most conspicuous trigger, but not the only one. The same chain breaks when the runtime environment changes, when an update replaces a template, when a new output path is added and when a cache keeps serving an old state; on that, see the article on finding stale content after a deploy. The calendar plays a part too: comparing analyses across a clock-change day shows differences that are none, as the article on daylight saving time in monitoring shows. And anyone consuming content through interfaces gets the encoding question along with deprecation deadlines from the other side; how that is announced is covered in the article on reading sunset and deprecation headers.

The standards are unambiguous here, and that makes continuous monitoring straightforward. The Encoding Standard requires authors to use UTF-8 and to identify it with the “utf-8” label (Encoding Standard). The HTML Living Standard requires that the actual character encoding used to encode a document be UTF-8 regardless of whether a declaration is present (HTML Living Standard), and it requires authoring tools to default to UTF-8 for newly created documents (HTML Living Standard). An encoding declaration is required even when all characters are in the ASCII range, because it is needed for form input and for URLs generated by scripts (HTML Living Standard). Turn those four sentences into check rules and you have a test that can be automated; how technical checks of that kind run permanently is described in the article on automating technical SEO monitoring. How the findings come together in one view is shown in the demo environment.

Sources and standards

This article draws on the WHATWG Encoding Standard (preface, section 2 Security background, section 4.1 Encoders and decoders, section 4.2 Names and labels), the WHATWG HTML Living Standard (section 4.2.5.4 on the encoding declaration and section 13.2.3 on determining the encoding), RFC 3629 on UTF-8, RFC 9110 section 8.3 on Content-Type, and the documentation of the open-source database MariaDB on Unicode character sets. These are standards texts and vendor documentation throughout, not surveys: they say what is supposed to hold, not how often it is departed from. Every figure in the article is attributed to its source.

How do I tell whether only the rendering or already the stored data is affected?
Fetch the same content two ways: once as a page in the browser and once as a raw request from the command line. If the raw request looks right and the page looks wrong, the fault is in the encoding declaration. If the broken characters are already in the delivered bytes, the stored data is usually affected. If the text contains replacement characters, the original information is typically gone and the way back runs through a backup.
Is it enough to add the encoding declaration to the document?
Only if no contradicting charset is sent in the header field. If the transport layer specifies a supported encoding, that one applies (HTML Living Standard) and the declaration in the document has no effect. In addition the declaration must appear in full within the first 1024 bytes of the document (HTML Living Standard); if a long comment or an embedded script pushes it past that point, it is not found.
Why does the home page look right and the product page does not?
Because the two draw their text from different sources. Menu, headings and footer often live in template files that were copied over unchanged during the migration; product copy, descriptions and customer entries come from the database and travel through the connection whose encoding changed during the move. That is why a spot check on the home page is typically not a sound proof.
What does the diamond with the question mark in the middle of a text mean?
That is U+FFFD, the replacement character. A decoder inserts it in the “replacement” error mode when a byte sequence is invalid in the assumed encoding (Encoding Standard). It is not a rendering fault but an indication that information is already missing at that point. Several different invalid sequences produce the same character, which is why the original content generally cannot be recomputed from it.
In which order should the database and the application be converted?
Typically the backup first, then the connection, then the columns, then the application, each step on its own with a measurement in between. Changing connection and columns at the same time makes it impossible afterwards to tell which step produced the broken characters. When moving from utf8mb3 to utf8mb4, a supplementary character takes four bytes instead of at most three (MariaDB documentation); experience shows it is worth checking beforehand whether index lengths and field limits are laid out for that.
How often should the encoding be checked?
On every occasion: every migration, every update that replaces templates, and every new output path, and beyond that permanently as a text comparison across the site. A daily or weekly comparison is sufficient for most sites, because an encoding break typically arrives with a release rather than creeping in. What matters is coverage more than frequency: the whole site should be checked, not the home page.
Start in 60 seconds

Let XICTRAQ watch your website.

Permanent free plan available. No credit card, no installation, no hidden fees.