Type mapping
What your MySQL TINYINT(1) / ENUM / DECIMAL / JSON / temporal types become on Postgres (and vice versa), and on SQLite / D1 — the cross-engine translation policies.
sluice never translates one dialect straight to another. Every column type maps source-dialect → typed IR → target-dialect: source-specific knowledge lives in readers, target-specific knowledge in writers, and the IR is the only shared contract. That's why the four-direction matrix needs four readers and four writers, not twelve pairwise tables. This page is the operator-facing summary of those policies; the canonical, always-current source is docs/type-mapping.md and the runtime value contract in docs/value-types.md.
Core vs extension types #
The IR type system is a two-tier hierarchy, and the tier decides what happens on an engine that lacks a type:
- Core types — integers, decimal, float, boolean, char/varchar/text, binary/blob, date/time/datetime/timestamp, JSON — are the types every relational engine has in some form. Every engine reads and writes them; they are the lingua franca.
- Extension types —
ENUM,SET,UUID, arrays, PostGIS geometry, and the Postgres network types (inet/cidr/macaddr) — are types only some engines support natively. Each engine's own reader/writer type dispatch decides what it handles; an engine that lacks one either applies a documented degradation (e.g. Postgres array → MySQLJSON) or refuses loudly. Postgres extension types (hstore,citext,pgvector, PostGIS) are opt-in via--enable-pg-extension EXTand refuse loudly at schema-read if the flag is absent (SLUICE-E-SCHEMA-EXTENSION-NOT-ENABLED) — never silently dropped.
Adding a new engine never amends the core; it provides the reader/writer code, and its own type dispatch IS the declaration — a missing arm refuses loudly rather than degrading silently. The orchestrator's capability questions are strategy questions (which bulk-load method, which CDC transport, which schema scope). And since v0.119.0 the target engine is asked to dry-run its own column-type emit before any data moves, so an unrenderable column is refused up front — naming the table, column and type — instead of surfacing mid-run from the target's CREATE TABLE.
MySQL ↔ Postgres #
The most-travelled direction. Notable rows below; the full table is in the canonical doc.
mysql rows here in both directions; the divergences are all on the catalog side, not the type table — MariaDB's native uuid / inet6 / inet4 types, its per-table (not per-schema) CHECK-constraint names, a geometry SRID it stores but won't echo in SHOW CREATE, and a different COLUMN_DEFAULT dialect. The MariaDB field notes cover each.| MySQL | Postgres | Notes |
|---|---|---|
TINYINT(1) | boolean | The MySQL boolean convention. A value outside {0,1} would collapse to true, so sluice REFUSES loudly (SLUICE-E-VALUE-TINYINT1-RANGE) at the first such value — a fail-fast preflight catches it before the copy — rather than silently carrying it. Remedy: change the source column type away from TINYINT(1) (e.g. MODIFY … SMALLINT), which works on every source; or, for a non-Vitess MySQL bulk migrate, --type-override col=smallint to keep the integer (smallint is the safe floor — a tinyint override could round-trip back to a boolean). The override does not apply on a PlanetScale/Vitess source, whose type comes from the replication wire. |
TINYINT / SMALLINT / MEDIUMINT / INT / BIGINT | smallint / smallint / integer / integer / bigint | MEDIUMINT widens to integer on PG (no 3-byte int). Signed ranks map straight across. |
… UNSIGNED | widens one rank | tinyint→smallint, smallint→integer, mediumint/int→bigint. bigint unsigned → bigint (uniform): PG has no unsigned 64-bit, so values in (2^63-1, 2^64-1] aren't representable — but this is the only mapping that keeps an AUTO_INCREMENT PK and its FK children type-consistent (the default Rails/Laravel/Django schema). Surfaced by a loud range-narrowing notice at schema preview / migrate preflight; override to numeric to keep the full range (then the column can't be an identity key). |
DECIMAL(p,s) / NUMERIC | numeric(p,s) | Carried as a string end-to-end; precision is lossless. A bare Postgres numeric (no p/s) is arbitrary-precision — PG→PG round-trips it bare; PG→MySQL widens to DECIMAL(65,30) (MySQL's max) with a loud widening notice. A value that exceeds it is refused, never rounded: past 35 integer digits MySQL refuses the write itself (Error 1264), and since v0.156.4 a value with more significant fractional digits than the target column's scale — past 30 for DECIMAL(65,30), or past a constrained DECIMAL(p,s)'s scale — refuses with SLUICE-E-VALUE-UNREPRESENTABLE marked DECIMAL-SCALE-EXCEEDED, as does a DECIMAL column's literal DEFAULT past the scale. Trailing zeros past the scale lose nothing and are not refused. To carry every digit, --type-override the column to a text type; otherwise round on the source. See values past the scale for the repair on a MySQL target written by an earlier release. |
FLOAT / DOUBLE | real / double precision | Finite values ride through exactly. NaN/±Inf are Postgres-only, so PG→MySQL refuses them loudly with SLUICE-E-VALUE-UNREPRESENTABLE (MySQL has no non-finite floats) rather than coerce them; MySQL→PG never produces one. |
CHAR(n) / VARCHAR(n) / TINY..LONGTEXT | char(n) / varchar(n) / text | A PG varchar(N) above MySQL's representable cap down-maps to the smallest MySQL TEXT-family type, with a loud advisory. Charset/collation are carried same-engine, dropped-with-WARN cross-engine (collation names aren't portable). That drop is a DDL-time policy: since v0.159.0, at CDC apply each primary- and unique-key column's source collation is compared with the target column's, and where they differ an upsert that would merge two source rows refuses with KEY-COLLATION-MERGE. Cross-engine that reaches a Postgres key with an explicit deterministic collation on a MySQL-family _ci column, a Postgres default-collation UNIQUE key beside a primary key on a MySQL-family target (the primary key itself is deliberately left unarmed), and a MySQL-family key on a Postgres column with a nondeterministic collation, which refuses KEY-COLLATION-GUARD-UNSUPPORTED at the first write because Postgres has no collation to compare it as. A Postgres target key on a deterministic collation never matches two different values and carries no check. Since v0.159.1 the copy phase's idempotent writer (a resumed or chunk-resumed copy, --force-cold-start, schema add-table, the VStream COPY, restore of a rotated chain) carries the same check, and also arms a Postgres default-collation or nondeterministic key inside the primary key; a Postgres nondeterministic UNIQUE key beside a primary key is checked at CDC apply too. A plain fresh copy fails loudly on such a collision instead. A SQLite or D1 source records no collation, so its plain key columns, including on the sqlite-trigger and d1-trigger streams, are not checked (KEY-COLLATION-SQLITE-SOURCE-UNSTAMPED, open); since v0.161.0 a functional unique key part on a MySQL-family target arms for them. At CDC apply the check covers live sync start and chain restore; sync from-backup is checked only since v0.160.1 (v0.159.0 through v0.160.0 applied every incremental unchecked, KEY-COLLATION-BROKER-UNARMED), and a pgtrigger source's live sync or chain restore into a MySQL-family target is checked only since v0.161.0, which reads each table's shape from the source catalog at its first change of every session (KEY-COLLATION-TRIGGER-CDC-NO-HISTORY). Since v0.161.0 a MySQL-family target's functional unique-key part is checked too (KEY-COLLATION-FUNCTIONAL-KEY-PART). Values are always carried as UTF-8; a column in a non-UTF-8 charset is converted on the change stream too (v0.156.3) — see non-UTF-8 character sets. |
DATE / TIME(p) / DATETIME(p) / TIMESTAMP(p) | date / time(p) / timestamp(p) / timestamptz(p) | MySQL TIMESTAMP always stores UTC → PG timestamptz. A bare PG time/timestamp (no precision) round-trips bare PG→PG but materializes (6) on a MySQL target. A PG timetz → MySQL drops the zone (MySQL has no tz-aware time). Zero/partial MySQL dates (0000-00-00) are refused unless --zero-date=null|epoch (SLUICE-E-VALUE-ZERO-DATE). |
ENUM('a','b') | enum type (default) or text + CHECK | Default emits a PG CREATE TYPE … AS ENUM; per-column override for text + a CHECK constraint. A PG enum → MySQL becomes a column-level ENUM(...) (no shared type; each column gets its own). |
SET('a','b') | text[] + CHECK | Membership preserved via a CHECK; override to a comma-delimited text. |
JSON | jsonb (default) / json | MySQL JSON and PG jsonb both validate + normalise; PG json (no b) preserves whitespace/key order. Carried as raw bytes. |
| (no MySQL type) | uuid | PG uuid → MySQL CHAR(36) / BINARY(16). |
JSON (degraded) | T[] (array) | MySQL has no array type: a PG array → MySQL JSON (empty {}→[], NULL element→JSON null, nested preserved). No array-specific override exists: to land a column as parseable text instead, force it with --type-override TABLE.COL=text. Multi-dimensional arrays are pinned per element family — see the field note on the pgx codec that silently flattened numeric[][]. |
VARCHAR(45/30) | inet / cidr / macaddr | PG network types have no MySQL native form: inet/cidr→VARCHAR(45), macaddr→VARCHAR(30) (auto-shaped since v0.7.0; overridable). |
| spatial types | geometry (PostGIS) | Requires PostGIS on the target via --enable-pg-extension; carried as WKB. Every subtype/SRID preserved. Toward MySQL: a Z/M-dimensional column refuses at preflight since v0.124.0 (MySQL 8 has no Z/M geometry — previously the copy aborted mid-run on the server's raw 1416), a 2D geography lands as planar geometry (bytes + SRID exact) with the geodesic→planar flatten surfaced as a schema preview note, and a value with NaN/Inf coordinates (incl. POINT EMPTY) refuses with SLUICE-E-VALUE-UNREPRESENTABLE. |
Decimal values past the target scale: DECIMAL-SCALE-EXCEEDED (v0.156.4) #
An unconstrained Postgres numeric lands on a MySQL-family target as DECIMAL(65,30), MySQL's widest fixed-point form: 35 integer digits and 30 fractional digits. Past 35 integer digits MySQL refuses the write itself (Error 1264). Past 30 fractional digits it does not: MySQL and MariaDB round the value and report only Note 1265, which even strict sql_mode accepts. The bulk copy (LOAD DATA and the batched INSERT path) was always loud, because it refuses a strict-mode write whose warning list is non-empty; the CDC applier never read that list. So through v0.156.3, sync (log-based or postgres-trigger), chain restore, sync from-backup and the added-column backfill stored such values rounded, at exit 0 — measured on MySQL 8.0.46 and MariaDB 11.4: 0.1234567890123456789012345678901 landed as 0.123456789012345678901234567890, and -0.99999999999999999999999999999999 as -1.000000000000000000000000000000. A DECIMAL column's literal DEFAULT past the scale was stored rounded the same way on CREATE TABLE and on a forwarded ADD COLUMN.
Since v0.156.4 sluice refuses any DECIMAL value, and any DECIMAL column's literal DEFAULT, whose significant fractional digits exceed the target column's scale — before the driver sees it, with SLUICE-E-VALUE-UNREPRESENTABLE marked DECIMAL-SCALE-EXCEEDED, naming the column and the digits lost. It applies to a constrained DECIMAL(p,s) column too, and to a float column retyped to a narrower DECIMAL with --type-override. Trailing zeros past the scale lose nothing and are not refused (1.5000…0 lands as 1.5). To proceed, round the value on the source if the digits are not needed, or --type-override the column to a text type to carry every digit. numeric[] values land in a JSON column as strings and were always exact.
sync, chain restore or sync from-backup), compare its numeric-sourced DECIMAL columns against the source and re-copy the affected rows: a value with more fractional digits than the target scale is stored rounded, and upgrading does not find or fix it. A target DEFAULT taken from such a source column holds the rounded value — re-declare it from the source. Remaining, stated: a value for a column the applier's descriptor map does not know at all (a cold cache) is not checked. A Postgres target's numeric(p,s) also rounds over-scale input without notice, reachable only through a narrowing --type-override or a pre-existing narrower target column.Non-ASCII text in MySQL-family DEFAULTs and expressions #
Row values never pass through the catalog, so they were never affected by what follows. Schema text does: sluice builds the target's DEFAULTs, generated columns, CHECK constraints and functional indexes from the source catalog, and both MySQL's and MariaDB's information_schema render some non-ASCII text unfaithfully. sluice recovers it; where it cannot recover it faithfully, it refuses the schema read, naming the object, rather than guessing.
- A literal DEFAULT beyond the Basic Multilingual Plane (v0.156.1). Both servers store
COLUMN_DEFAULTin utf8mb3, soVARCHAR(20) DEFAULT '😀x'reads back as?x. A character default that reads back with a?is re-read — through aDEFAULT()probe on MySQL and MariaDB, fromSHOW CREATE TABLEon PlanetScale and Vitess — and must agree with the catalog text, or the read refuses; a genuine?default stays?. Refused rather than guessed: such a default on a non-UTF-8 column charset on PlanetScale/Vitess, and anENUM/SETdefault whose label holds such a character. - Expressions — an expression
DEFAULT (…), a generated column, aCHECK, a functional index (v0.156.2, Bug 288). MySQL (and PlanetScale, Vitess) stores each string literal of an expression in the encoding its charset introducer names and renders every stored byte as a separate character, soDEFAULT ('é')read back as_utf8mb4'é'and, through v0.156.1,élanded on the target aséand中as丗 onmigrate,synccold start,restoreand a forwardedADD COLUMN, to MySQL and Postgres targets alike. sluice now undoes the widening and decodes each literal by its introducer: utf8mb4, utf8mb3 and binary bytes as UTF-8, latin1 bytes as their latin1 characters. It refuses a latin1 byte in 0x80–0x9F (MySQL's latin1 is cp1252 there — 0x80 is€), a literal in any other charset (gbk, …), and a non-ASCII literal with no introducer. The decoded expression must also agree with that object's ownSHOW CREATE TABLEline, or the read refuses. MariaDB'sinformation_schemais faithful for the BMP but writes one?per byte for a character beyond it (an emoji became????, on every LTS line from 10.11 to 12.3); sluice takes theSHOW CREATE TABLEtext the?s match, searched only within the object's own clause, and refuses on no match or an ambiguous one.
SHOW CREATE TABLE, re-declare those that differ, and recompute a generated column whose expression was wrong (a STORED column keeps its wrong values until re-declared). v0.156.1's notes said a character DEFAULT beyond the BMP reaches the target intact; that was true of literal defaults only. A mydumper dump of a MySQL source carries each expression as SHOW CREATE TABLE printed it, so a character beyond the BMP in one is already ???? in the dump and cannot be recovered from it.Column names inside expressions, on a Postgres target (v0.160.1) #
MySQL and SQLite resolve a column name inside a stored expression case-insensitively, and MySQL keeps a functional index's expression as typed, so KEY (plant, (lower(status))) over a column named STATUS is valid there. Postgres folds every unquoted identifier to lower case. Through v0.160.0 sluice carried such an expression across with its identifiers bare, so a MySQL functional index, generated column or CHECK, or a SQLite partial-index predicate, that named an upper- or mixed-case column failed on a Postgres target with SQLSTATE 42703 (column "…" does not exist) — even when the case matched exactly. A generated column or CHECK failed at CREATE TABLE; a functional index failed at the index phase, after the whole copy, so a sync start cold start stopped with every row copied and no CDC position (reported as SLUICE-E-INDEX-TARGET-MISSING). Nothing was lost (GitHub #356). Since v0.160.1 the Postgres writer resolves each bare column reference against the table's columns, case-insensitively as the source does, and emits the column's real name, quoted when Postgres would otherwise fold it. LEADING, TRAILING and BOTH inside TRIM(…) need a position rule, because sluice strips the backticks that tell MySQL's column `both` from the keyword both, and either misreading is valid Postgres that trims different characters. The word stays the keyword only as the first token of TRIM's arguments with an operand after it (TRIM(BOTH 'x' FROM s)); before FROM, ) or an operator it is the column (trim(`both` from `code`) trims the characters held in column both). v0.160.1 got this wrong: it kept the three words bare anywhere inside TRIM, so a column used as the remove-string became the keyword and a generated column or CHECK computed different values at exit 0 (Bug 311, fixed in v0.160.2). schema diff translates a CHECK the same way, so the quoted form is not reported as drift. MySQL and SQLite targets were never affected. Recovery: a stream that already failed this way refuses a plain re-run with SLUICE-E-COLDSTART-TARGET-NOT-EMPTY; after upgrading, re-run it with --reset-target-data --yes.
TRIM with a multi-character remove-string: TRIM-REMOVE-STRING-SEMANTICS (v0.160.3) #
MySQL and Postgres both accept TRIM(BOTH 'xy' FROM s) and mean different things by it: MySQL strips 'xy' as a repeated substring, Postgres strips any x or y character as a set. TRIM(BOTH 'xy' FROM 'yxAyx') is yxAyx on MySQL and A on Postgres. Through v0.160.2 a generated column, CHECK, view or unique expression index using such a TRIM crossed between the two verbatim and computed or accepted different values at exit 0 (HIGH, silent, Bug 312; the basic MySQL → Postgres case affects every earlier release, and v0.160.1 and v0.160.2 each made more shapes silent). Since v0.160.3 sluice refuses it, in either direction, with TRIM-REMOVE-STRING-SEMANTICS naming the object. The two engines agree only when the remove-string is a text literal of at most one byte, optionally cast to text, varchar or character varying, so everything else is refused: a longer literal; a column or expression (including a column named both, Bug 311's shape), whose value is unknown until a row arrives; a one-character non-ASCII literal (over a binary column, VARBINARY/BLOB/bytea, the engines trim by byte); a hex or collated literal; NULL; and any other cast, such as a fixed-length char cast (on Postgres ' '::char(1) loses its trailing space and trims nothing). Some of those are refused conservatively where both engines would in fact agree. The likeliest real trigger is the Postgres whitespace idiom trim(both E' \t\r\n' from s), which on MySQL strips only exact repeats of that four-character run.
Remedy: for a generated column, supply a target-valid body with --expr-override <table>.<column>=<expression>; for a CHECK or index, drop it on the source and re-create the equivalent on the target after the run, or leave the table out with --exclude-table; for a view, leave it out with --exclude-view <name> and create the equivalent on the target. On Postgres a MySQL substring-trim can be written with regexp_replace; on MySQL a Postgres set-trim can be written as REGEXP_REPLACE(s, '^[xy]+|[xy]+$', ''). The column, CHECK and index refusals reach migrate, sync start's cold start and resume, schema add-table, restore, chain restore and CDC schema-forward; a view is refused at the views phase and in schema preview. Not refused, by design: non-unique expression indexes (they change no value and no accepted row), DEFAULT expressions (every migrated row carries its value explicitly), and same-engine runs. SQLite in either direction is outside this refusal: its own translator admits only one-argument trim. If you migrated MySQL ↔ Postgres with such an expression on an earlier release: compare generated columns and view results with the source's (sluice verify --depth count does not see it), search the target schema for trim(, rewrite each object for the target and re-copy the table.
Views: what a copied view reads (v0.160.4) #
sluice copies a view's body as text. MySQL writes the database name into every table reference of a stored view, and Postgres writes the schema name whenever it is not on the reading session's search path. Through v0.160.3 that body was copied verbatim, so a view landed in the target database or schema but kept naming the source's: where the target server also held a database or schema by that name (including the source itself, when both live on one server), the view read it at exit 0, returning another namespace's rows; otherwise it failed loudly (HIGH, silent, Bug 314, long-standing). Reached: MySQL → MySQL into a differently named database (including --map-database), Postgres → Postgres into a different schema (--target-schema, a target ?schema=, --map-schema, sync start), and Postgres → MySQL from a non-public schema. Since v0.160.4 the MySQL reader removes the source database's own qualifier, leaving string literals and references to any other database untouched; the Postgres reader reads view bodies with the source schema as the search path, so the source's own names come back unqualified and every other schema's qualified; and the Postgres writer runs each CREATE VIEW and CREATE MATERIALIZED VIEW with the target schema on the search path (public follows it only for a view read from public, whose extension calls are unqualified).
VIEW-QUALIFIER-AMBIGUOUS(refusal). A MySQL view that also uses its own database's name as a table alias (FROM t app), a derived table's name or a CTE's name cannot have the qualifier removed safely:`app`.`id`may be the alias's column, and removing it could make a correlated subquery read another column without any error. sluice refuses such a view by name before creating any view. The check also over-refuses some safe uses: a column alias inGROUP BY/HAVING, aWINDOWname, or a CTE column list. Rename the alias in the source view (CREATE OR REPLACE VIEW), or leave the view out with--exclude-view <name>and create it on the target yourself after the run.VIEW-BODY-CROSS-ENGINE(refusal). A MySQL view going to Postgres or SQLite, and a Postgres view from a non-publicschema going to MySQL or SQLite. Their qualifiers used to make theCREATEfail; with them removed, the text would parse on the other engine and could compute differently (MySQL'sa / bis3.5000where Postgres and SQLite give3;s = 'abc'matches'ABC'under a MySQL case-insensitive collation and not elsewhere). Leave such a view out with--exclude-view <name>and create an equivalent on the target. A Postgres view frompublic, or a SQLite view, is copied verbatim as before; check its results on the target, because the same differences apply. This newly refuses a Postgres multi-schema fan-out into MySQL (and a restore of such a backup into MySQL), where a non-publicview used to create because its qualifier named the MySQL database sluice had just created.
Corrections (Bug 316). The v0.160.4 notes said a name the run did not create (such as a table left out with --exclude-table) now fails loudly. That holds only when the target schema is not public and does not already hold a same-named object: if the target schema is public, or already holds a table by that name, the view binds to that target table at exit 0. A public → public run binds the same way on every release, and so does MySQL → MySQL now. And both refusals fire before any data is copied per database or schema, not per run: in a multi-namespace fan-out the namespaces processed before the refused one are already copied, so re-run with --exclude-view against a fresh target, or with --reset-target-data --yes for a sync. A Postgres view calling an extension installed outside public (a Supabase-style extensions schema) may now fail at the views phase; leave it out with --exclude-view and create it on the target.
Still open: a view over a different schema that the same run also renames (--include-schema app,public --map-schema app=app2 with a public view over app.t keeps naming app); and restoring a backup taken before v0.160.4, which replays its recorded, qualified bodies — take a fresh full backup on v0.160.4 or later if you restore into a differently named database or schema. If you migrated views that way on an earlier release: check each target view's definition (SELECT view_definition FROM information_schema.views WHERE table_schema = DATABASE() on MySQL, pg_get_viewdef on Postgres) and recreate any that names the source.
Columns in a non-UTF-8 character set: the change stream (v0.156.3) #
A legacy schema's CHAR/VARCHAR/TEXT columns are often declared latin1, cp1251, sjis, gbk, utf16 or another non-UTF-8 charset. They map to the same IR string type as a utf8mb4 column, and the value is always carried as UTF-8. The bulk copy (migrate, a sync cold start, a backup full) never sees the stored bytes: it reads over a utf8mb4 connection, so the server converts every value first. The change stream does see them — the binlog row image (MySQL, MariaDB) and the VStream row event (PlanetScale / Vitess) both carry a character column's value as the bytes stored in its own charset. Since v0.156.3 sluice converts those bytes by the column's charset, so sync, backup stream and backup incremental carry what the bulk copy carries: every 8-bit charset, latin1 as MySQL's cp1252 variant, the Japanese, Korean and GB charsets, the UTF-16/UTF-32/UCS-2 family, and swe7 (not ASCII-compatible: { is ä). Each conversion table is graded against the server's own CONVERT(… USING utf8mb4) over its whole code space on MySQL 8.0 and MariaDB 11.4. Keys and before-images are converted too, so a row updated or deleted by a non-ASCII text key is found on the target. A PlanetScale/Vitess column with a _bin collation (latin1_bin, utf8mb4_bin, …) arrives typed VARBINARY/BLOB/BINARY and is decoded as text by its collation; the declared column type decides, so a true binary column stays bytes.
What cannot be converted faithfully refuses with CHARSET-NOT-DECODABLE, naming the table, column and charset (the value is not printed):
- a non-ASCII
big5value — no available table matches MySQL'sbig5(the common one disagrees in 267 places); - on PlanetScale / Vitess, any non-ASCII value in a
gbk,big5,tis620orgb18030column — vttablet sends those with collation 0, so the stream cannot tell the charset (the binlog lanes convertgbk,tis620andgb18030normally); - the
tis620bytes MySQL itself converts to U+FFFD; - on PlanetScale / Vitess, a non-UTF-8
ENUM/SETcell that is a member under both readings. vttablet sends such a cell as UTF-8 label text in CDC but as its stored bytes in the COPY phase, with identical field metadata, so sluice resolves each cell against the column's own labels; it refuses only when two labels are each other's mis-encoding (a latin1ENUM('é','é')). Rename one label, or exclude the table. The index-0''is a member of everyENUM.
Pure-ASCII values in those columns stream normally. To proceed past the refusal, convert the column to utf8mb4 on the source and re-snapshot the table (sync … --restart-from-scratch), or exclude the table. Do not convert and resume: the stream replays history written in the old charset.
A charset change while the stream is behind it. A replay of history recorded before an ALTER … CHARACTER SET (a warm resume after downtime, a lagging stream, a cold-start handoff whose copy spanned the ALTER) carries bytes in the old charset.
- MySQL 8, and MariaDB under
binlog_row_metadata=MINIMALorFULL: each row event's TABLE_MAP records the collation the value was written in (MySQL 8 writes it under its defaultMINIMAL), and sluice decodes by it, so the replay is exact. Only a collation ID neither the source's collation table nor sluice can name refuses, withSLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH. - MariaDB under its default
binlog_row_metadata=NO_LOG, and PlanetScale / Vitess, record no written charset (vttablet reports a replayed row's current collation). Rows are decoded by the current charset, and each table with non-UTF-8 text columns logsCHARSET-HISTORY-UNRECORDEDonce. When the stream reaches anALTERthat sets a non-UTF-8 charset explicitly (CONVERT TO CHARACTER SET, orMODIFY/CHANGEwith aCHARACTER SETorCOLLATE), sluice compares the charset and collation it sets with the shape it has been decoding the table by; a match means the stream is replaying, and it refuses withSLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH. The rows replayed before thatALTERwere already emitted, so the remedy depends on the command: undersync, re-snapshot the table (--restart-from-scratch); forbackup stream/backup incremental, take a fresh full backup. A backup window that decoded rows by the new charset and ended before theALTERis refused before it commits (CHARSET-HISTORY-UNRECORDED,SLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH, take a fresh full backup). A change intoutf8mb4/utf8mb3/utf8is not guarded, so routine UTF-8 DDL never refuses a live stream; its replay carries the old bytes as v0.156.2 carried every value.
NO_LOG and PlanetScale / Vitess only. For a change from utf8mb4 into a non-UTF-8 charset these are silent where v0.156.2 was exact (it carried a utf8mb4 value's stored bytes, which are its UTF-8); for a change between two non-UTF-8 charsets they are silent where v0.156.2 was loud or silent by the bytes. If a stream was behind such a change, re-snapshot (or take a fresh full backup) yourself:
- a
syncrestart between the replayed rows and theALTER— the next run reaches theALTERwith no decoded rows to compare, and the backup window check does not reachsync; - a backup window that decoded the replayed rows after an earlier window had already recorded the charset change (its start and end schema agree);
- a second charset change to a table the same reader session already crossed an
ALTERon; - a
MODIFY/CHANGEthat changes a column's charset implicitly (noCHARACTER SET/COLLATE, taking the table default), and a table-default change (ALTER TABLE … DEFAULT CHARSET); - an online schema change — gh-ost, pt-online-schema-change, PlanetScale deploy requests and Vitess online DDL — which swaps in a shadow table with a
RENAMEand never streams anALTER … CHARACTER SETon the table; - an unclassifiable statement that names no charset keyword at all.
ALTER setting a non-UTF-8 column to the charset and collation it already has, a replayed collation-only change on such a column, and a charset ALTER the parser cannot classify on a table with non-UTF-8 columns all refuse, live or not. On MariaDB, set binlog_row_metadata=MINIMAL (or FULL) so each row event records the charset it was written in; it applies only to binlog events written after the change — history already written stays unrecorded.backup stream / backup incremental recorded every non-ASCII value of such a column as � (U+FFFD) or as a different character, silently — take a fresh full backup with v0.156.3; chain restore, sync from-backup and backup verify now WARN with LEGACY-CHARSET-INCREMENT naming each affected incremental's table and columns. Under sync, a value whose raw bytes happened to be valid UTF-8 landed as a different character at exit 0 (a latin1 é as é; every non-ASCII swe7 value), and a later UPDATE or DELETE keyed on such a value never found its row; the rest failed loudly at the target. Compare those columns against the source for rows changed after cutover and repair from the source, or re-copy the table (--restart-from-scratch). Full operator detail: docs/operator/migrating-legacy-mysql.md in the sluice repo.SQLite & Cloudflare D1 #
SQLite (and D1, which is SQLite over HTTP) is the one engine whose value storage isn't pinned by its column declaration — a column has a type affinity, and each stored value carries its own storage class. sluice resolves an IR type from the declared type in a load-bearing order: declared temporal / bool spellings win first, affinity second.
| SQLite declared / affinity | IR → typical target | Notes |
|---|---|---|
DATE / DATETIME·TIMESTAMP / TIME | date / timestamp (no tz) / time | Declared spelling overrides affinity (they'd otherwise read as NUMERIC decimals). The value encoding is an operator choice — --sqlite-date-encoding (iso default / unixepoch / unixmillis / julian); a storage-class mismatch is refused loudly, naming the row. |
BOOL / BOOLEAN | boolean | Decodes 0/1 and truthy text; anything else is refused. |
| INTEGER affinity | bigint | SQLite integers are 64-bit signed. Integers above 253 round-trip exactly via the (typeof, text/hex) projection (the lossless live-D1 reader path). |
| TEXT affinity | text | Unbounded — declared VARCHAR(n) lengths aren't enforced by SQLite, so no misleading bound is carried. |
| REAL affinity | double precision | 8-byte IEEE-754. |
| NUMERIC affinity | unconstrained numeric | Arbitrary precision. |
As a migrate target, SQLite emits the declared type its reader reads back to the same IR type. The one load-bearing wrinkle: an ir.Decimal is stored with TEXT affinity (the exact decimal string), not NUMERIC — NUMERIC affinity would coerce 19.99 to the binary float 19.989999999999998 and silently corrupt money (Bug 162); it reads back as text (a documented downgrade). Anything SQLite has no faithful storage for — geometry, inet/cidr/macaddr, bit, interval, array, domain — is refused loudly at emit time, never coerced to a silently-wrong column. D1 is not a write target: emit a SQLite .db (--target-driver sqlite) and wrangler d1 import it.
Column-fidelity markers you will see in the log #
Some schema facts have no spelling on the target, or have one that only some paths can use. sluice never drops one silently: each carries a grep-stable marker on a WARN line — or, where carrying on would land a wrong value, on the refusal that stops the run — so you can search a run's logs for it.
| Marker | What it means |
|---|---|
IDENTITY-ALWAYS-DOWNGRADED | A Postgres GENERATED ALWAYS AS IDENTITY column was created on the target as GENERATED BY DEFAULT AS IDENTITY, so the bulk copy can land the source's ids (an ALWAYS column refuses an explicit value with SQLSTATE 428C9). One WARN per run, naming every affected table.column. sync cold start, restore and chain restore keep BY DEFAULT deliberately — each hands the target to a change applier that writes explicit ids, which ALWAYS would refuse — as do a shard-consolidation and a --force-cold-start migrate, which are not the last bulk load into the table. On those paths the target accepts application-supplied ids the source rejected; the remedy after the last explicit-id write is ALTER TABLE … ALTER COLUMN … SET GENERATED ALWAYS per named column. |
IDENTITY-ALWAYS-RESTORED | migrate restored ALWAYS after the copy and the constraints phase, on the tables it created itself this run. A table that already existed on the target (the schema preview → apply-yourself flow) is never altered and stays BY DEFAULT. |
IDENTITY-OPTIONS-NOT-CARRIED | Toward MySQL or SQLite, neither the ALWAYS/BY DEFAULT mode nor the identity sequence's options exist; one WARN per column, and the column lands as a plain AUTO_INCREMENT / rowid alias that accepts explicit values and steps by one (MySQL: by the server's auto_increment_increment). Toward a Postgres target the options — START, INCREMENT, MINVALUE, MAXVALUE, CACHE, CYCLE — are carried exactly on every path. |
GENERATED-VIRTUAL-PROMOTED-TO-STORED | A VIRTUAL generated column (a PostgreSQL 18 source, or a MySQL VIRTUAL column) landed STORED on the target: the invariant survives, the storage tradeoff changes. It stays VIRTUAL on a Postgres target at 18 or newer; it is promoted on an older target, and on PG 18+ for a column the source indexes (MySQL allows that; PostgreSQL 18 refuses CREATE INDEX on a virtual generated column) so the index can build. |
INDEX-COLLATION-DROPPED | A SQLite/D1 index column's non-BINARY collation (NOCASE, RTRIM) was dropped on a Postgres or MySQL target, naming the columns. Only ever on a non-unique index, where the collation changes ordering and cost rather than which rows are legal — on a PRIMARY KEY, a UNIQUE or a constraint-backed unique the migration is refused at preflight instead, before any data moves. See Keys, indexes, and index collation. |
INERT-FLAG | You passed a tuning flag the resolved engine pair never reads — --bulk-parallelism on a Vitess-source sync start, --max-target-connections on a SQLite/D1 target. Before ADR-0118 such a flag parsed, nothing consumed it, and you got the unchanged behaviour with no signal. One WARN naming the flag, the command, the engine it is inert on and why — with the knob that applies instead where one exists. Nothing is refused. |
LOB-DEFAULT-NOT-CARRIED | A DEFAULT on a TEXT, BLOB, JSON or GEOMETRY column that a MySQL-family target cannot hold. Since v0.156.1 sluice carries such a DEFAULT to a MySQL 8.0.13+ (PlanetScale and Vitess included) or MariaDB target in the parenthesised form both accept — DEFAULT ('txt'), DEFAULT (X'00AB00'), DEFAULT ('{"a": 1}') — translated per value family; through v0.156.0 every such DEFAULT was dropped. What still cannot be carried — any such DEFAULT on a MySQL older than 8.0.13 or whose version could not be read, a geometry literal, an array of an unsupported element type, a bytes literal whose encoding is ambiguous — is handled by where it appears. On CREATE TABLE (migrate, a sync cold start, restore) the column is created without it and this WARN names the table, column and reason; the copied rows carry their own values, so only later inserts that omit the column are affected. A forwarded ADD COLUMN is instead refused before the target ALTER with SLUICE-E-VALUE-UNREPRESENTABLE naming this marker, because the target would fill every row it already holds with NULL where the source holds the default: add the column on the target yourself with the right value for the existing rows, then resume. |
DECIMAL-SCALE-EXCEEDED (refusal) | Since v0.156.4: a DECIMAL value — or a DECIMAL column's literal DEFAULT, on CREATE TABLE and a forwarded ADD COLUMN alike — with more significant fractional digits than the MySQL, MariaDB or PlanetScale/Vitess target column's scale, typically a Postgres unconstrained numeric past 30 decimal places landing in DECIMAL(65,30). MySQL and MariaDB would round it and report only Note 1265, which even strict sql_mode accepts; sluice refuses before the driver sees it with SLUICE-E-VALUE-UNREPRESENTABLE, naming the column and the digits lost. Trailing zeros past the scale are not refused. Round on the source if the digits are not needed, or map the column to a text type with --type-override. See values past the scale. |
ENUM-LABEL-NOT-RECOVERABLE (refusal) | A MySQL, MariaDB or PlanetScale/Vitess ENUM/SET label holding a character outside the Basic Multilingual Plane (an emoji). The server keeps the label intact in the table, but every catalog surface — information_schema, SHOW CREATE TABLE, mysqldump — writes that character as ?, so the target's type is created with '?b' (a schema-read WARN says so). The copy fails loudly at the row's INSERT. CDC (sync, backup stream) names ENUM positions and SET bits through the catalog, so since v0.156.1 a row using such a label stops the stream with this marker, naming the table, column and label — through v0.156.0 it landed as '?b', silently. On a binlog source running binlog_row_metadata=FULL the true labels are read from each row event and the stream carries on; VStream always refuses. A label that genuinely is '?' on a utf8mb4/utf16/utf32 column is refused the same way. Recovery: SET PERSIST binlog_row_metadata = 'FULL' (and widen the target column or rename the labels, since the target's type still holds '?b'), rename the source labels to BMP characters, or --type-override=TABLE.COL=text. If you streamed such a column on an earlier release, compare it against the source. |
CHARSET-NOT-DECODABLE (refusal) | A MySQL-family change stream (binlog or VStream) met a value in a non-UTF-8 character column that it cannot convert to UTF-8 faithfully: a non-ASCII big5 value; on PlanetScale/Vitess, a non-ASCII value in a gbk, big5, tis620 or gb18030 column (vttablet sends those with collation 0); the tis620 bytes MySQL maps to U+FFFD; or a PlanetScale/Vitess ENUM/SET cell that is a member under both readings. Names the table, column and charset, not the value. Remedy: convert the column to utf8mb4 on the source and re-snapshot (--restart-from-scratch) — never convert and resume — or exclude the table. See non-UTF-8 character sets. |
CHARSET-HISTORY-UNRECORDED | A WARN, once per table with non-UTF-8 text columns, on a source whose change stream records no written charset — MariaDB under the default binlog_row_metadata=NO_LOG, and PlanetScale/Vitess. A replay across a charset change there is decoded by the column's current charset; a replay across an ALTER into a non-UTF-8 charset refuses with SLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH when the stream reaches the ALTER (sync: re-snapshot the table; backups: take a fresh full). The marker also rides the refusal (with SLUICE-E-CDC-SCHEMA-REPLAY-MISMATCH) that a backup incremental / backup stream window gives, before it commits, when the column's charset changed into a non-UTF-8 charset between the window's start and end schema while its rows were decoded by the new one. Some windows stay silent — see non-UTF-8 character sets. On MariaDB, binlog_row_metadata=MINIMAL records charsets for events written after it is set. |
LEGACY-CHARSET-INCREMENT | A WARN (the command carries on) from chain restore, sync from-backup and backup verify, for each incremental that sluice v0.156.2 or earlier captured from a MySQL-family source with a non-UTF-8 text column, naming the table and columns. That incremental may hold each non-ASCII value it changed in those columns as � or as a different character, and the chain cannot give the value back: take a fresh full backup with v0.156.3 or later and repair rows restored from the old chain against the source. Restoring a bare full and export-as-parquet read no incremental and do not WARN. |
BACKUP-VALUE-NOT-UTF8 (refusal) | backup full, backup incremental or backup stream met a text value that is not valid UTF-8 — a SQLite TEXT value written from raw bytes, a Postgres SQL_ASCII database, legacy invalid bytes in a MySQL utf8mb4 column. Backup chunks are JSON, and through v0.156.2 such a value was written as � at exit 0. Names the table, column, change image and nested position; backup stream stops rather than retrying, and no manifest is committed for the refused window. Remedy: repair the value at the source, or store it as BLOB/bytea. Meanwhile backup full can skip it with --exclude-table or --redact <table>.<column>=null; backup incremental and backup stream have neither and stop until the source is repaired. See Backups. |
TRIM-REMOVE-STRING-SEMANTICS (refusal) | Since v0.160.3: a generated column, CHECK, view or unique expression index crossing between MySQL and Postgres uses TRIM with a remove-string other than a text literal of at most one byte, which the two engines strip differently. Names the object and a remedy (details). |
VIEW-QUALIFIER-AMBIGUOUS (refusal) | Since v0.160.4: a MySQL view uses its own database name as a table alias, derived-table name or CTE name, so its database qualifier cannot be removed safely. Rename the alias, or --exclude-view it (details). |
VIEW-BODY-CROSS-ENGINE (refusal) | Since v0.160.4: a MySQL view going to Postgres or SQLite, or a Postgres view from a non-public schema going to MySQL or SQLite, whose body would parse on the other engine but could compute differently. --exclude-view it and create an equivalent on the target (details). |
Per-column overrides #
The default policies cover the common case; override per column in YAML (mappings:) or on the CLI. Overrides are typed against the IR, not dialect syntax:
--type-override TABLE.COLUMN=TYPE— force a target column type (repeatable). The override rewrites the IR type the reader decodes with, so e.g.=smallinton aTINYINT(1)reads the cell as an integer end-to-end.--enable-pg-extension EXT— opt into a Postgres extension type (hstore,citext,vector, PostGIS) so its columns pass through instead of refusing.- YAML
mappings:entries taketable:,column:,target_type:, and free-formtarget_type_options:(e.g.binary: true) — the file form of--type-override. Zero-date policy is the process-global--zero-date error|null|epochflag (per-sync via thesync runfleet config'szero-datekey), not a per-column mapping.
Run sluice schema preview first to see the exact target DDL sluice would emit, including every widening/narrowing advisory and any untranslatable-expression refusal — before touching the target.
