Cloud & Migration

The Migration Completed Successfully. Half the Names Are Now Question Marks.


The cutover ran clean. impdp reported completed successfully, the row counts matched to the row, the application came up, and everyone went home. Three weeks later a support ticket lands: a customer named José is in the system as Jose, and a partner in Tokyo shows up as ??. Not a rendering bug — the database really holds Jose and ?? now. The originals are gone, and no backup from after the cutover has them either.

Here’s the uncomfortable part: nothing failed. No error was raised, no row was rejected, no count was off. The migration did exactly what you told it to — you just told it to pour Unicode data into a container too narrow to hold it, and Oracle quietly trimmed everything that didn’t fit. This post is what character-set data loss actually is, why every check you ran said “success,” the one test that catches it, and a lab that reproduces the loss on demand and then shows the migration that keeps every character.

What actually happens

A character set is the map between the characters you see and the bytes stored on disk. Oracle has two that matter here: the database character set (NLS_CHARACTERSET), which governs VARCHAR2/CHAR data, and the client’s declared character set (NLS_LANG), which tells the server what the bytes arriving from a client mean. Modern databases use AL32UTF8 — Unicode — which can represent every character in every language. Many older databases used a narrow set: US7ASCII (7-bit, 128 characters), WE8MSWIN1252 (Western European), WE8ISO8859P1, and so on.

When data moves from a wider character set into a narrower one — an export/import into a legacy target, a CREATE TABLE AS across a database link, a client conversion — Oracle must map each character into the target set. For a character the target can represent, fine. For one it can’t, it does one of two things, and both are data loss:

  • Silent transliteration. A character with a rough ASCII equivalent is replaced by that equivalent. José becomes Jose, Müller becomes Muller, François becomes Francois. It looks completely plausible — which is exactly why it sails through QA and lands in production.
  • Replacement. A character with no mapping at all becomes a replacement character, almost always ?. A CJK name becomes ??; an emoji or an unusual symbol becomes ?. Obvious once someone looks — but nobody looks at every row.

Crucially, this conversion is a normal, “successful” operation. It does not raise an error. It does not reject the row. The row count is identical before and after. Every signal a migration runbook checks says the load worked.

Why every check said “success”

The standard post-migration checks all operate at the wrong level:

  • Row counts compare how many rows moved, not what’s in them. José and Jose are both one row.
  • “0 errors” / completed successfully means no row was rejected. Character-set conversion isn’t a rejection — it’s a transformation the database is happy to perform.
  • Spot-checking finds the ?? names if you happen to look at one, but misses the transliterated ones entirely, because Jose looks correct.

The loss is in the values, so you have to compare the values — specifically, whether each value would survive a round-trip through the target character set. That’s the check almost nobody runs, and it’s the whole game.

flowchart TD
S["Source: AL32UTF8 (Unicode)<br/>José · Müller · 田中 · Smith"] --> T{"Target character set?"}
T -->|"US7ASCII (7-bit)"| A["José→Jose, Müller→Muller<br/>田中→??<br/>5 of 10 names damaged"]
T -->|"WE8MSWIN1252 (Western Euro)"| B["accents kept<br/>田中→??<br/>CJK name lost"]
T -->|"AL32UTF8 (Unicode superset)"| C["every character preserved<br/>0 loss"]
A --> R["all report: rows match, 0 errors<br/>-> loss is invisible to naive checks"]
B --> R
C --> R
The same Unicode (AL32UTF8) rows loaded into three target character sets. Every path reports success and keeps all the rows -- the difference is only in the values. A US7ASCII target strips Latin accents to plain ASCII (Jose) and turns anything with no mapping into '?'; a WE8MSWIN1252 (Western European) target keeps the accents but still loses the CJK name; only a Unicode (AL32UTF8) target, a superset, preserves every character. Row counts match and no error is raised in all three cases.

Reproduce it

You don’t need a real migration to see this. Build a 10-row customers table in an AL32UTF8 database — five plain-ASCII names and five with accents or CJK characters — then model loading it into a narrower target with CONVERT(name, target_charset, 'AL32UTF8'), which does exactly what the target database’s conversion does on import. Migrating into US7ASCII:

rows after migration: 10   errors raised: 0
names damaged by a US7ASCII target: 5 of 10   (of those, turned into '?': 1)
names still damaged by a WE8MSWIN1252 (Western European) target: 1
   José         ->  Jose     (US7ASCII target)
   Müller       ->  Muller   (US7ASCII target)
   Renée        ->  Renee    (US7ASCII target)
   François     ->  Francois (US7ASCII target)
   田中          ->  ??       (US7ASCII target)

Ten rows in, ten rows out, zero errors — and half the names quietly damaged. Four were transliterated into plausible-but-wrong ASCII; one became ??. Note the middle row of the story: even a Western European target (WE8MSWIN1252), which happily keeps José and Müller, still turns the CJK name into ??. “Wide enough for our data” is a judgment that ages badly the moment you onboard a customer from a new region.

Detect it — before cutover

The test that catches what row counts miss is a round-trip: convert each value to the target character set and back, and compare it to the original. Anything that comes back changed would be damaged by the migration:

-- pre-migration scan: which rows would a US7ASCII target silently damage?
select id, name
from   customers
where  convert(convert(name,'US7ASCII','AL32UTF8'),'AL32UTF8','US7ASCII') <> name;

In the lab this flags exactly the five non-ASCII rows, in advance. This is precisely what Oracle’s Database Migration Assistant for Unicode (DMU) — and the legacy csscan utility — do for real: scan every column of every table and report the data that is lossy (can’t be represented in the target) or would need byte expansion. Run it against the source before you migrate, not against the wreckage afterward.

The fix

  • Migrate into a Unicode target (AL32UTF8). Unicode is a superset of every legacy Oracle character set, so every source character has a representation and nothing is lost. In the lab, the same data migrated into AL32UTF8 has a round-trip loss of zero. If you’re modernizing anyway, this is the answer: go to AL32UTF8 and stop having this problem forever.
  • If you must land in a narrower set, scan first and remediate. Use DMU/csscan to find lossy data, then fix it at the source (correct the values, or widen the target) before cutover — never discover it after.
  • Set the client NLS_LANG correctly on every tool in the pipeline. More on this trap below; a wrong NLS_LANG corrupts data even when both databases are Unicode.
  • Watch column length semantics when migrating up to Unicode. Going from a single-byte set to AL32UTF8, characters that were one byte can become up to four. A VARCHAR2(10 BYTE) column that held a 10-character name may no longer fit — the import fails with ORA-12899, or worse, silently truncates. Define columns with CHAR semantics (VARCHAR2(10 CHAR)) or size for the byte expansion.

What teams get wrong

  • Trusting the row count and the “completed successfully”. Both are true and both are irrelevant to character-set loss. The count tells you rows moved; it tells you nothing about whether the values survived.
  • Thinking ? is the only symptom. The ?-marks are the lucky case — they’re visible. The silent transliteration (José→Jose) is the dangerous one: it looks right, passes review, and you find out when a customer complains that you’ve been spelling their name wrong for a month.
  • The NLS_LANG pass-through trap. The other classic corruption isn’t a narrow database — it’s a wrong client. If a tool inserts UTF-8 bytes while its NLS_LANG claims US7ASCII, Oracle believes the lie, does no conversion (or the wrong one), and stores garbage — even when the database is AL32UTF8. “Both sides are Unicode” does not save you if a client in the middle misdeclares its character set. Set NLS_LANG on every client, exporter, and loader.
  • Assuming “Western European is wide enough”. It holds accented Latin, so it survives a demo full of French and German names — and destroys the first Japanese, Greek, Arabic, or emoji-bearing value it meets.
  • Migrating first, scanning never. DMU/csscan exist to be run before the migration. Skipping the scan is choosing to find your data loss in production.
  • No backup of the pre-migration source. Character-set loss is irreversible: once José is stored as Jose, the accent is gone. If you didn’t scan and you didn’t keep the source, there is nothing to recover from. Keep the source readable until you’ve verified the target at the value level.

Frequently asked questions

What causes Oracle character-set data loss during a migration?

It happens when data moves from a wider character set into one that cannot represent all of its characters -- most commonly Unicode (AL32UTF8) data being loaded into a narrow legacy set like US7ASCII or WE8MSWIN1252. Oracle must map each character into the target set, and for characters the target cannot hold it either transliterates them to a rough ASCII equivalent (José becomes Jose, the accent silently dropped) or replaces them with a replacement character, usually '?' (a CJK name becomes ??). This conversion is treated as a normal, successful operation: no error is raised, no row is rejected, and the row count is unchanged, so it passes every count-based migration check while quietly destroying data. A separate but related cause is a mismatched client NLS_LANG, where a tool misdeclares the character set of the bytes it is sending and Oracle stores them incorrectly even when both databases are Unicode.

Why did my migration report success but the data is corrupted?

Because character-set conversion is not an error condition. The checks a migration runbook relies on -- 'completed successfully', zero rejected rows, and matching source and target row counts -- all operate on rows and errors, not on the contents of the values. When Oracle converts a character it cannot represent in the target set, it transforms the value (to a transliteration or a '?') rather than failing, so every row still loads, no error is logged, and the counts match exactly. The loss lives inside the values, which none of those checks inspect. To see it you have to compare the actual values, typically with a round-trip conversion test, rather than trusting the success message.

How do I check for character-set data loss before migrating?

Run a round-trip test: convert each value to the target character set and back to the source set, and compare it to the original -- any value that comes back changed cannot survive the target set. In SQL that is a query like WHERE CONVERT(CONVERT(col,'US7ASCII','AL32UTF8'),'AL32UTF8','US7ASCII') <> col, run against every character column. For a real migration, use Oracle's Database Migration Assistant for Unicode (DMU) or the legacy csscan utility, which scan every column of every table and report which data is lossy (cannot be represented) or would need byte expansion. The essential discipline is to run this against the source before cutover, not to discover the damage in production afterward.

What is the difference between transliteration and '?' replacement?

They are the two ways Oracle handles a character the target set cannot represent, and both are data loss. Transliteration substitutes a rough equivalent that the target set does have -- José becomes Jose, Müller becomes Muller -- so the result is readable and plausible but wrong; this is the more dangerous case because it passes visual review and QA, and you may not notice for weeks. Replacement is used when there is no equivalent at all: the character becomes a replacement character, almost always '?', so a CJK or Arabic name becomes a string of question marks. Replacement is obvious once someone looks at the row; transliteration is silent. Both are irreversible once the original value has been overwritten.

Does migrating to AL32UTF8 (Unicode) cause data loss?

Migrating into AL32UTF8 does not lose characters, because Unicode is a superset of every legacy Oracle character set -- every source character has a representation, so the conversion is lossless. That direction is the fix, not the risk. There are two things to watch, though. First, column length semantics: going from a single-byte set to AL32UTF8, a character that was one byte can become up to four, so a VARCHAR2(n BYTE) column may no longer fit its data and the load fails with ORA-12899 or truncates -- define columns with CHAR semantics or size for the expansion. Second, a wrong client NLS_LANG can still corrupt data during the load even with a Unicode target, if a tool misdeclares the character set of the bytes it sends. The data loss described here is specifically migrating the other way, into a narrower set.

What is the NLS_LANG pass-through trap?

It is character corruption caused not by a narrow database but by a client that misdeclares its character set. NLS_LANG tells the Oracle server what the bytes arriving from a client mean. If a tool actually sends UTF-8 bytes but its NLS_LANG claims the data is US7ASCII (or WE8MSWIN1252), the server believes the declaration and either performs the wrong conversion or none at all, storing mangled bytes -- and this happens even when both the source and target databases are AL32UTF8. It is a common surprise because teams assume that if both databases are Unicode the data is safe, but a single exporter, loader, or client session in the pipeline with a wrong NLS_LANG will corrupt the data in transit. The remedy is to set NLS_LANG to the true character set of the data on every client and tool involved in the migration.

Why did my import fail with ORA-12899 after moving to Unicode?

ORA-12899 (value too large for column) on a migration into AL32UTF8 is the byte-expansion trap. In a single-byte character set every character occupies one byte, so a VARCHAR2(10) column holds up to 10 characters. In AL32UTF8 a character can take up to four bytes, and if the column was defined with BYTE length semantics (the default), VARCHAR2(10 BYTE) still means 10 bytes -- so a 10-character name of accented or multibyte characters no longer fits and the insert fails. The fixes are to define the columns with CHAR semantics (VARCHAR2(10 CHAR), which counts characters regardless of byte length), to set NLS_LENGTH_SEMANTICS to CHAR before creating the tables, or to widen the columns to allow for the expansion. It is better to hit ORA-12899 (a loud failure) than silent truncation, so size deliberately rather than relying on defaults.

Can I recover data after a character-set migration lost it?

Not from the migrated database -- the loss is irreversible. Once José has been stored as Jose, the accent is simply gone; once a name has been stored as ??, the original characters are not recoverable from that value, because the conversion discarded information rather than encoding it differently. Your only recovery is the pre-migration source: the original database, an export taken before cutover, or a backup from before the load. This is exactly why the source must be kept readable and verified at the value level before it is decommissioned, and why the round-trip scan (or DMU/csscan) must be run before the migration. If the source is already gone and was already overwritten, the data cannot be reconstructed.

Character-set loss belongs to the same family of migrations that “succeed” without being correct: it is the values-level cousin of counting the rows after a Data Pump job, where a load reports success but the data isn’t whole — here the rows are all present and it’s the characters inside them that are missing. It’s the detail that turns a clean-looking cloud migration into a month-late incident. Treat “completed successfully” as the start of verification, not the end of it: scan the source for lossy data with DMU or a round-trip query, migrate into a Unicode (AL32UTF8) target so there is nothing to lose, set NLS_LANG correctly on every client, and prove it the way that ends the argument — with the character-set migration lab, where a US7ASCII target silently damages half the names and a Unicode target keeps every one.

Have a question or some feedback?

I write here in a personal capacity and enjoy comparing notes with other Oracle folks. Say hello.

Get in touch