# sluice > Open-source database migration and continuous-sync CLI: migrate and sync MySQL ↔ Postgres in all four directions, plus SQLite / Cloudflare D1 import and continuous sync — correctness-first, loud failure by default. sluice is a single Go binary. Every command is non-interactive (flags, environment variables, and an optional YAML config — no prompts, ever), destructive operations require explicit opt-in flags, credentials resolve env-first, and machine-readable output is available via `--log-format json` and per-command `--format json`. The documentation below is also maintained as markdown in the source repository — for LLM consumption the raw markdown links in the "Source-repo markdown" section are the best format. Each site page below is additionally served as token-slim Markdown at its own URL with `index.md` appended (e.g. `https://sluicesync.com/docs/getting-started/index.md`), linked from every page via ``. ## Documentation - [Overview](https://sluicesync.com/docs/): Migrate and continuously sync MySQL and Postgres, and import SQLite / Cloudflare D1 — correctness-first, loud failure by default. - [Getting started](https://sluicesync.com/docs/getting-started/): Install sluice, point it at a source and target, and run your first migration and continuous sync. - [Configuration](https://sluicesync.com/docs/configuration/): Connection strings, environment variables, the YAML config file, and the global flags every command shares. ## General Guides - [Migrate MySQL → Postgres](https://sluicesync.com/docs/migrate-mysql-to-postgres/): The flagship first migration: connect, preview the plan, copy the data, and verify it landed. - [Preview & validate before you migrate](https://sluicesync.com/docs/preview-and-validate/): See the exact target DDL, steer the type translation, and confirm the copy — without guessing. - [Verify & reconcile](https://sluicesync.com/docs/verify-reconcile/): Confirm every row landed and no structural drift crept in — then know exactly what to do when it didn't. - [Zero-downtime migration (continuous sync)](https://sluicesync.com/docs/zero-downtime-cutover/): Cold-start the data, let CDC catch up while the app keeps writing, then cut over in a brief, controlled window. - [Staged (wave) migration](https://sluicesync.com/docs/staged-wave-migration/): Move your biggest or most self-contained tables first, cut them over, then bring the rest across in later waves. - [Schema changes during a sync](https://sluicesync.com/docs/schema-changes/): How sluice keeps a running sync online while the source schema evolves — what forwards automatically, what refuses loudly, and how to recover. - [Redact PII](https://sluicesync.com/docs/redact-pii/): Seed staging, dev, analytics, and vendor handoffs from production without letting personal data leave with the rows. - [Import SQLite or Cloudflare D1](https://sluicesync.com/docs/import-sqlite-d1/): Move a SQLite file, a wrangler D1 export, or a live Cloudflare D1 into Postgres or MySQL — losslessly. - [Migrate many databases or schemas](https://sluicesync.com/docs/multi-database/): Fan a whole MySQL server or a multi-schema Postgres source out to same-named target namespaces in one run. - [Copy a subset of tables](https://sluicesync.com/docs/copy-table-subset/): Copy one-to-several tables from an existing Postgres database to a PlanetScale MySQL/Vitess (or plain MySQL) target and keep just those continuously in sync. - [Prepare a Postgres source](https://sluicesync.com/docs/postgres-source-prep/): What a Postgres source needs before it can feed a continuous sync — the required GUCs, the REPLICATION role attribute, replication-slot lifecycle, and the slot-less path for managed Postgres. - [Managed Postgres (slot-less)](https://sluicesync.com/docs/managed-postgres-slotless/): Heroku Postgres, RDS without grants, Supabase / Crunchy starter tiers — managed Postgres that forbids logical replication still streams via sluice's trigger-based postgres-trigger engine. No slot, no REPLICATION attribute. - [Operate a sync fleet](https://sluicesync.com/docs/operate-fleet/): Supervise many continuous syncs from one process — failure-isolated, observable, and reconfigurable without a restart. - [Take encrypted backups](https://sluicesync.com/docs/encrypted-backups/): sluice's logical backup model in depth — chains, compression, encryption at rest, object stores, retention, and restore. - [Sync from a backup chain](https://sluicesync.com/docs/from-backup-sync/): Replay a backup chain into a target as a long-running broker — no direct source↔target connectivity required. - [Drive sluice from an AI agent](https://sluicesync.com/docs/agent-skills/): sluice ships task-scoped agent skills — plain-markdown playbooks that let Claude Code, Cursor, or any skill-aware assistant run a migration, verify a sync, or operate a backup chain on your behalf, inside the same safety gate. ## Platform Guides - [Migrating from Neon](https://sluicesync.com/docs/migrate-from-neon/): Direct vs -pooler endpoints (and why CDC needs the direct one), the irreversible enable_logical_replication project setting, TLS interop, and the live-validated migrate + continuous-sync recipe. - [Migrating from Supabase](https://sluicesync.com/docs/migrate-from-supabase/): Session vs transaction Supavisor pooler modes, the IPv6-only free-tier direct endpoint and what it means for CDC, float display vs float identity, and the platform schemas sluice already scopes out. - [Migrating from a Supabase read replica](https://sluicesync.com/docs/migrate-from-supabase-read-replica/): A Supabase read replica (an -rr- endpoint) is a fine bulk-migrate source that offloads the copy's read load from your primary — PG 16+ standby parallel snapshots engage unreduced — but CDC is refused: a replica can't host the sluice publication, so continuous sync must point at the primary. Plus the corrected CDC-preflight facts and how to verify safely against a lagging replica. - [Migrating from DigitalOcean MySQL](https://sluicesync.com/docs/migrate-from-digitalocean-mysql/): The ~13–16-minute binlog purge window on defaults (and the config-API knob that fixes it), the private CA via --source-tls-ca, sql_mode differences, and the resnapshot-livelock hazard sluice warns about. - [Migrating from AWS RDS MySQL](https://sluicesync.com/docs/migrate-from-rds-mysql/): The detect-first binlog-retention advisory (what the WARN means and the one-line SQL remedy), 8.0-family parameter groups, the platform-blocked FTWRL, and the regional truststore bundle. - [Migrating from AWS RDS Postgres](https://sluicesync.com/docs/migrate-from-rds-postgres/): The rds.logical_replication parameter-group flip, the rds_replication role-membership model (nothing to grant for the master user), force_ssl and the trust bundle, and the trigger-engine fallback. - [Migrating from Google Cloud SQL Postgres](https://sluicesync.com/docs/migrate-from-cloudsql-postgres/): The cloudsql.logical_decoding flag (restart included, about a minute), the self-service ALTER ROLE ... REPLICATION grant, default-plaintext TLS and the per-instance CA, and the validated migrate + CDC recipe. - [Migrating from Google Cloud SQL MySQL](https://sluicesync.com/docs/migrate-from-cloudsql-mysql/): The first managed MySQL whose binlog retention is honest and safe by default (a 1-day floor), the --retained-transaction-log-days decoy, the PITR toggle that invalidates every position, and the per-instance-CA TLS recipe. - [Migrating from Azure Database for MySQL](https://sluicesync.com/docs/migrate-from-azure-mysql/): The one required knob: binlog_row_image defaults to MINIMAL, under which binlog CDC silently loses UPDATEs (Bug 193) — set it FULL first. In exchange, Azure has the safest binlog retention of any managed MySQL probed (an honest 0 = never-expire default, no reaper), zero-setup public-CA TLS, and an -azure version fingerprint. - [Migrating from Azure Database for PostgreSQL](https://sluicesync.com/docs/migrate-from-azure-postgres/): The self-grantable REPLICATION attribute (ALTER ROLE ... WITH REPLICATION works as the admin user), the best TLS story in the series (verify-full with zero setup against public roots), and the wal_level flip that needs an explicit restart. The built-in PgBouncer is General-Purpose-tier-plus only. - [Migrating from Vultr Managed MySQL](https://sluicesync.com/docs/migrate-from-vultr-mysql/): The binlog-retention hazard is the headline: an out-of-band reaper purges every binlog file ~10–16 minutes after creation while the variable reads 3 days — and uniquely among managed MySQL, Vultr exposes no retention knob at all. That makes CDC migrate-and-cutover-shaped: keep any pause well under 10 minutes. Same Aiven-derived platform as DigitalOcean, without DigitalOcean's fix. - [Migrating from Vultr Managed PostgreSQL](https://sluicesync.com/docs/migrate-from-vultr-postgres/): The only provider validated so far that ships CDC-ready with zero preparation: wal_level=logical and a REPLICATION-bearing admin role out of the box. Plus the pghoard_local platform-internal slot you must not drop, and Vultr's managed PgBouncer — the live counter-example where CDC actually traverses the pooler. - [Migrating from MariaDB](https://sluicesync.com/docs/migrate-from-mariadb/): MariaDB is a first-class MySQL-family source engine — use the mariadb driver, not mysql. Bulk migrate to vanilla MySQL or PlanetScale round-trips cleanly (validated live); the divergences are all on the catalog and CDC side — a different COLUMN_DEFAULT dialect, per-table CHECK-constraint names, a geometry SRID it stores but won't echo, native uuid/inet types, domain-based GTIDs for continuous sync, and MariaDB 11.4's new default collation that remaps on a MySQL target. ## PlanetScale Guides - [PlanetScale & Vitess](https://sluicesync.com/docs/planetscale-vitess/): Migrate and continuously sync from PlanetScale-MySQL or any Vitess deployment through the VStream gRPC feed. - [Self-hosted MySQL → PlanetScale](https://sluicesync.com/docs/mysql-to-planetscale/): Move an on-prem / self-hosted MySQL onto PlanetScale MySQL — a one-shot bulk migrate plus continuous binlog-CDC sync, so you cold-copy, keep the old primary writable, and cut over in a controlled window. - [Foreign keys on Vitess](https://sluicesync.com/docs/foreign-keys-vitess/): Migrating or syncing a foreign-key-bearing source into Vitess or PlanetScale MySQL — the two strategies (skip-and-index, or enable FK support) and how to choose. - [Online schema changes](https://sluicesync.com/docs/planetscale-schema-changes/): The expand→migrate→contract pattern as one gated command, the standalone resumable backfill with its verify gate, the deploy-ddl governed channel for safe-migrations branches, and the freshness gates that guard your production schema. - [PlanetScale Postgres](https://sluicesync.com/docs/planetscale-postgres/): PlanetScale Postgres is managed PostgreSQL, so sluice drives it with the plain postgres engine — native logical-replication CDC, not the planetscale driver. - [Upgrade PlanetScale Postgres](https://sluicesync.com/docs/planetscale-postgres-upgrade/): PlanetScale Postgres has no in-place major-version upgrade or CPU-architecture swap — provision a new instance on the target, let sluice sync across, then cut over. - [PlanetScale Postgres analytics replica](https://sluicesync.com/docs/planetscale-postgres-analytics-replica/): Analytics queries on a streaming replica get canceled after ~30 seconds of recovery conflict. Stand up a second PlanetScale Postgres database and let sluice keep it continuously synced — its primary never cancels a query for recovery, so analytics can run for minutes or hours. - [Move PlanetScale regions](https://sluicesync.com/docs/planetscale-region-move/): PlanetScale has no native region move — create the database in the new region and let sluice copy it across, with zero downtime or in one shot. - [Split rows by region](https://sluicesync.com/docs/split-rows-by-region/): Data-residency splits with a per-table --where predicate: keep US users in a US-region database and move only the EU users' rows into an EU-region database, in one shot or as a continuous filtered sync. ## Reference - [Supported directions](https://sluicesync.com/docs/supported-directions/): Every source → target pair sluice can move, for one-shot migrate and for continuous sync — and which pairs each surface does not cover. - [How sluice copies your data](https://sluicesync.com/docs/how-sluice-copies/): Same-engine vs cross-engine: which internal path a copy takes, and why the fast path never trades correctness for speed. - [Command reference](https://sluicesync.com/docs/commands/): Every sluice command, its purpose, the flags that matter most, and worked examples. - [Error & exit codes](https://sluicesync.com/docs/error-codes/): The stable SLUICE-E-* error codes and the process exit-code contract — a greppable branching surface for scripts, log pipelines, and agents driving the CLI. - [Type mapping](https://sluicesync.com/docs/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. - [Objects sluice creates](https://sluicesync.com/docs/database-objects/): The full inventory of sluice's bookkeeping tables, slots, publications, and triggers — what each is for, when it appears, and how to remove it. ## Field Notes - [Field Notes](https://sluicesync.com/field-notes/): War stories from building a correctness-first migration tool — the engine behaviors, wire-protocol edges, and silent-corruption classes we hit, and what we changed because of them. - [2026-07-20 · MySQL 8 has this collation column; MariaDB added it in 12.1](https://sluicesync.com/field-notes/mariadb-no-pad-attribute-column/): MySQL 8 has an information_schema.COLLATIONS.PAD_ATTRIBUTE column that says whether a collation is PAD SPACE (trailing spaces ignored in =) or NO PAD. MariaDB shipped without it for years — absent through the whole 11.x LTS line and 12.0, added only in 12.1 — so on the MariaDB most people run, the attribute that decides whether 'EU' matches a stored 'EU ' is not in the catalog and you read the _nopad_ collation name instead. A portable reader can't assume the column is present or absent across versions; the version-robust signals are the name token and the server's own behavior, and sluice's parity gate anchors to both. - [2026-07-18 · The = that ignores your trailing spaces](https://sluicesync.com/field-notes/collation-pad-space-trailing-space/): MySQL's WHERE region = 'EU' matches a stored 'EU ' — on a legacy collation. Every collation except the 8.0 utf8mb4_0900_* family is PAD SPACE, which ignores trailing spaces in comparison; the modern default is NO PAD and doesn't. That single per-collation attribute is easy to miss, and a client-side comparator that reproduces a collation's case and accent folding but not its pad attribute silently disagrees with the source on the most common legacy collation. This one shipped as a real silent row-loss — and it shipped green because the test compared the comparator to itself instead of to a real server. - [2026-07-18 · You can't reimplement MySQL's =, so link its comparator in](https://sluicesync.com/field-notes/reuse-the-source-comparator/): A filtered change stream evaluates the same predicate in two places — pushed down to the source, and client-side per event — and they have to agree exactly or the stream silently leaks or drops rows. String equality is where they diverge, because MySQL's default collation is case- and accent-insensitive: 'EU' equals 'eu' equals 'Eu'. sluice first refused such filters rather than approximate them. The resolution wasn't a better approximation — it was to stop reimplementing the comparison and link in the source engine's own comparator, so the two evaluations are the same code by construction. - [2026-07-18 · The change stream that won't drop your row](https://sluicesync.com/field-notes/vstream-filter-keeps-both-images/): Filtering a continuous change stream has one genuinely hard case: a row updated so it no longer matches the filter has to become a target DELETE, or the now-out-of-scope row leaks forever. When you push that filter server-side into someone else's stream — a Vitess VStream rule — the load-bearing question is what the stream emits for a row that leaves the filter. Assume it drops the event and you leak. The Vitess source settles it: for a non-vindex filter, when either the before- or after-image passes, VStream emits the change with BOTH images — so the move-out arrives as a full UPDATE, never dropped. The catch is that it tells you the row touched the filter, not which side matched, so you still have to decide the move direction yourself. - [2026-07-17 · You can't filter a parent table without orphaning its children](https://sluicesync.com/field-notes/filter-a-parent-orphans-the-child/): Row-level filtering — copy only the rows matching --where — reads like a per-table setting: give each table a predicate, keep the rows that match. But a relational schema couples those filters through its foreign keys. Filter a parent table down to a subset and the child rows you copied still point at parent rows the filter excluded, so the deferred ADD CONSTRAINT FOREIGN KEY fails with SQLSTATE 23503 on the target. A tool that filtered the parent quietly would hand you a database that looks complete and violates its own declared keys. sluice refuses loudly instead, names the constraint, and makes you choose how to reconcile. - [2026-07-17 · The predicate you evaluate twice has to agree, or refuse](https://sluicesync.com/field-notes/predicate-in-two-engines/): Continuous filtered sync applies one --where predicate in two places: pushed down to the source for the initial snapshot, and evaluated client-side per event for the change stream, because no source delivers a filtered stream. If the two evaluations can disagree, the stream silently leaks or drops rows — and string equality is the canonical trap, because equality itself is collation-defined, not byte-defined. A byte-exact client-side compare of name = 'ANA' diverges from a case- or accent-insensitive source collation. So sluice restricts the client-side grammar to what it can reproduce faithfully and refuses everything else loudly at sync-start, rather than approximate a comparison the source would answer differently. - [2026-07-17 · The optimization that trimmed away the column a later feature needed](https://sluicesync.com/field-notes/optimization-trimmed-the-column/): Continuous filtered sync — replicate only the rows matching --where — has one genuinely hard case: an UPDATE can move a row out of the filter's scope, and that has to become a target DELETE or the now-out-of-scope row silently leaks. sluice designed exactly that, a before×after row-move truth table. Then end-to-end testing over a real change stream caught it leaking anyway, because both CDC readers already narrow the UPDATE before-image down to the primary key — an earlier correctness fix — so by the time the filter evaluated the OLD row, the filtered column was no longer in it. A data-narrowing optimization can silently defeat a feature added later that needs the trimmed-away data, and neither piece's own unit test can see it. - [2026-07-17 · MariaDB and MySQL 8 disagree on which coordinate comes first](https://sluicesync.com/field-notes/mariadb-geometry-axis-order/): Migrate a POINT in SRID 4326 from MariaDB to MySQL 8 and a naive ST_AsText diff shows the longitude and latitude swapped — POINT(-122.4194 37.7749) on the source reads POINT(37.7749 -122.4194) on the target. Nothing is corrupt. sluice copied the WKB faithfully and re-attached the SRID; the point is in the same place, and ST_Latitude/ST_Longitude match to the digit. The two engines just default to opposite axis orders when they render a geographic SRID as text. Compare the coordinates, not the string. - [2026-07-17 · MariaDB 11.4's default collation doesn't exist on MySQL 8](https://sluicesync.com/field-notes/mariadb-default-collation-remap/): Migrate a MariaDB 11.4 schema to a MySQL-family target and sluice WARNs on nearly every string column — because 11.4 made utf8mb4_uca1400_ai_ci (UCA 14.0.0) the server default, and no MySQL 8 server implements it. sluice maps each affected column to the closest equivalent MySQL 8 has, utf8mb4_0900_ai_ci (UCA 9.0.0), preserves every byte, and surfaces the swap. This note is why that WARN is correct-by-design, not a data problem: the bytes are intact; only the collation weights (and PAD semantics) for a small set of characters change. - [2026-07-17 · ACTIVE_HEALTHY through a five-minute recovery](https://sluicesync.com/field-notes/active-healthy-not-liveness/): Flooding a 1 GB Supabase Micro instance with WAL pushed it into crash recovery — FATAL 57P03, every connection refused for five and a half minutes while it replayed — and the Supabase Management API kept reporting status=ACTIVE_HEALTHY. A control-plane status field is an assertion of intent, not a data-plane liveness signal, so it is misleading for backend readiness: probe with a real query. The finding rode a separate, concrete result: a logical replication slot's WAL runway is set by the compute tier (512 MB on Micro, 2 GB on Small), not by the PITR add-on, live-proven with a paired differential. - [2026-07-17 · gocloud classifies "301" by substring](https://sluicesync.com/field-notes/gocloud-classifies-301-by-substring/): sluice's backup-chain concurrent-writer guard is a compare-and-swap on S3's create-only conditional PUT: the loser of two racing PUTs gets a 412 PreconditionFailed, mapped to a coded conflict refusal. It was reading that 412 through gocloud's portable error class — and gocloud's s3blob classifier carries strings.Contains(err.Error(), "301"). S3 stamps a random hex RequestID on every response, so whenever that hex happens to contain the digits 301 — about 2% of requests — a genuine 412 is misclassified as NoSuchBucket. Classify from the structured API error, never from a substring of the rendered one. - [2026-07-17 · The read replica is a better migrate source and a worse CDC source than the docs](https://sluicesync.com/field-notes/read-replica-source-pg16/): "You can't do logical replication from a read replica" is Postgres ≤15 lore, and PG 16 quietly flipped both halves of it — in opposite directions for a migration tool. On the read side it got better than the docs: pg_export_snapshot() now works on a standby, so a parallel bulk copy from a replica is fully snapshot-consistent. On the CDC side it got more fragile than "impossible": a slot can be created on a PG 16+ standby, but CREATE_REPLICATION_SLOT blocks until the idle primary emits its next running-xacts record, and the publication DDL CDC needs can't run on a hot standby at all. - [2026-07-17 · The row image you can't preflight, because a proxy is in the way](https://sluicesync.com/field-notes/proxy-cant-preflight-row-image/): A self-hosted Vitess running binlog_row_image=NOBLOB drops an unchanged BLOB from an UPDATE's after-image — the same silent-overwrite class as the binlog NOBLOB case, reached through the Vitess door. But the vanilla defense, reading @@GLOBAL.binlog_row_image before the stream starts, cannot exist here: sluice connects to a vtgate, a proxy in front of a fleet of tablets, and there is no single row-image posture to read. The only authoritative signal is the wire itself — and the tablet underneath is loud when the experimental flag is off and goes silent at exactly the setting the guard exists for. - [2026-07-17 · MariaDB has no BEGIN, and won't tell you if your position survived](https://sluicesync.com/field-notes/mariadb-gtid-no-begin/): Porting a MySQL binlog CDC reader to MariaDB surfaces two assumptions MySQL quietly baked in. A MariaDB transaction opens with a MariadbGTIDEvent and no BEGIN, so a pump that only handles MySQL's GTIDEvent never advances its position. And you cannot pre-check whether your resume position still exists: @@gtid_binlog_state is unchanged across PURGE BINARY LOGS, so a dead position looks live — the only honest signal is the stream itself throwing error 1236. - [2026-07-17 · The type that migrates clean and corrupts under CDC](https://sluicesync.com/field-notes/mariadb-native-type-cdc/): MariaDB's native uuid/inet6/inet4 round-trip perfectly under a bulk migrate, because the driver hands them back as formatted text. Turn on CDC and the same columns can corrupt: the binlog carries the raw storage bytes, not the text — bulk copy and the binlog are different transports with different representations. sluice met that first with a loud refusal, then decoded the bytes faithfully one release later — and the real byte layout was not the one the roadmap predicted, which is the richer lesson. - [2026-07-17 · MariaDB accepts a geometry SRID it won't show you](https://sluicesync.com/field-notes/mariadb-geometry-srid-hidden/): Declare POINT REF_SYSTEM_ID=4326 and MariaDB really stores the SRID — but SHOW CREATE TABLE drops the attribute and echoes a bare `point DEFAULT NULL`, and unlike MySQL 8 there is no srs_id column in information_schema.COLUMNS. Parse the SRID the documented way and every geometry column silently reads back as SRID 0. The declared value lives only in the OGC-standard GEOMETRY_COLUMNS view. - [2026-07-17 · The join that's 1:1 on MySQL 8 and fans out on MariaDB](https://sluicesync.com/field-notes/mariadb-check-constraint-fanout/): MariaDB has no distinct JSON storage type — a JSON column is LONGTEXT plus an auto-generated CHECK named after the column — and its constraint names are unique per table, not per schema. A catalog join that is provably 1:1 on MySQL 8 becomes a cartesian fan-out on MariaDB, and because JSON columns are named after their column it fires for the most ordinary schema imaginable: two tables that each have a `meta` JSON column. The fix cannot be symmetric, because MySQL 8's CHECK_CONSTRAINTS has no TABLE_NAME column to join on. - [2026-07-17 · The retention variable that tells five different truths](https://sluicesync.com/field-notes/managed-mysql-binlog-retention/): binlog_expire_logs_seconds is the variable every MySQL CDC preflight checks to learn how long it has before the resume position is purged. On five managed platforms it means five different things: on two it lies (the number is days, the real window is minutes); on one it is honest and enforced; on one it is honest the other way (never-expire); and on one there is no knob behind it at all. Whether anything SQL-visible is the answer decides whether a tool can detect the trap or only guess at it from the hostname. - [2026-07-17 · The platform default that eats every UPDATE](https://sluicesync.com/field-notes/binlog-row-image-minimal/): Point a MySQL CDC pipeline at Azure Database for MySQL and the cold copy is exact, the stream stays green, the counts stay equal — and every UPDATE silently vanishes. The cause is a one-line server default nobody set on purpose: Azure ships binlog_row_image=MINIMAL, and under MINIMAL an UPDATE's before-image carries only the primary key. Azure is the first major managed platform to make MINIMAL the out-of-box posture. - [2026-07-17 · The one-line fix that unpinned itself through the pooler](https://sluicesync.com/field-notes/float-pin-through-pooler/): A server that ships extra_float_digits=0 rounds every float Postgres renders as text, and sluice renders floats as text in four different sessions. The fix reads like one line — SET extra_float_digits = 3 before you read. It is four pins and a transaction, because a bare SET followed by a COPY silently lands on two different backends under a transaction-mode pooler, the fix's own error hint was steering users onto that pooler, and one of the four sessions is the verifier — which had been blessing the corruption it exists to catch. - [2026-07-17 · Your floats are fine; your diff tool is comparing two renderings](https://sluicesync.com/field-notes/extra-float-digits/): One server sets extra_float_digits=0, another runs the modern default of 1, and every text-level float comparison between them reports differences that do not exist. The stored bits are identical; only the rendering moved. The reassuring direction of this bug — data exact, report wrong — is exactly what makes it waste hours: everything you inspect by hand re-renders through the same setting that skewed the report. - [2026-07-17 · pgx's AfterConnect replaces, it doesn't chain](https://sluicesync.com/field-notes/pgx-afterconnect-replaces/): pgx stdlib gives you one slot to run setup on each new physical connection. Install two features through it — a session GUC pin and a PostGIS codec registration — and the second silently evicts the first. Whichever you register last is the only one that runs. No error, no warning; one of your two features just quietly stops working, on exactly the connections that need it. - [2026-07-17 · information_schema reports a numeric scale of 2046](https://sluicesync.com/field-notes/numeric-scale-2046/): Ask information_schema.columns for the scale of a numeric(5,-2) column and it answers 2046. The real scale is -2. The standards-blessed, portable way to read numeric precision and scale is quietly wrong for every negative-scale column — because the catalog view forgot to sign-extend a field the rest of Postgres sign-extends. - [2026-07-16 · MariaDB reports its defaults in a different dialect](https://sluicesync.com/field-notes/mariadb-catalog-default-dialect/): "MariaDB is basically MySQL" survives the wire protocol and dies in information_schema. Point the same DDL at both and COLUMN_DEFAULT speaks two dialects: MySQL 8 reports a string default as bare abc, MariaDB reports 'abc' with the quotes; a defaultless nullable column is SQL NULL on MySQL and the four-character string NULL on MariaDB; DEFAULT CURRENT_TIMESTAMP reads as current_timestamp() with an empty extra. A MySQL-convention reader emits corrupted defaults — and MariaDB's SYSTEM VERSIONED tables and SEQUENCEs vanish behind the near-universal table_type='BASE TABLE' filter. - [2026-07-16 · The heartbeat aged seven hours at write time](https://sluicesync.com/field-notes/heartbeat-aged-seven-hours/): A concurrent-run guard refuses to start a second backfill walk while the state row's heartbeat is fresher than five minutes. The heartbeat was a bare timestamp written with CURRENT_TIMESTAMP — which Postgres renders in the server's timezone before storing — while the reading driver scans a bare timestamp back as UTC. Two individually defensible behaviors compose into an age reading wrong by exactly the server's UTC offset, and the sign of that offset picks your failure mode. It shipped because every CI container runs UTC, where the bug is arithmetically invisible. - [2026-07-16 · The privilege catalog is not the permission system](https://sluicesync.com/field-notes/privilege-catalog-not-permission-system/): Three live-proven ways a cloud provider's privilege catalog lied, in both directions. On RDS the master role shows rolreplication=f permanently yet creates and drops logical slots, because the capability is gated on membership in rds_replication, not the role attribute — so the stock "rolsuper OR rolreplication" preflight false-refuses the entire platform. On RDS MySQL, SHOW GRANTS shows RELOAD yet FLUSH TABLES WITH READ LOCK returns 1045. And a capability probe checked pg_create_event_trigger — a predefined role that exists in no stock PostgreSQL. The catalog describes a permission model; it is not the permission model. - [2026-07-16 · The crash was the good outcome](https://sluicesync.com/field-notes/crash-was-the-good-outcome/): sluice's trigger-based Postgres CDC captures change images with to_jsonb(), and JSON has no token for a non-finite float — so ±Infinity and NaN travel as the strings "Infinity"/"NaN". The decoder normalized scalar leaves but skipped array elements, so the first UPDATE touching a float8[] column crashed the apply loop loudly and re-crashed on every restart. That loud crash was the value contract working: the fix not taken — blindly coercing every array leaf to float64 — would have truncated a numeric[] element and turned a text[] holding the literal word "Infinity" into a number. - [2026-07-16 · The parent table that returns rows it doesn't own](https://sluicesync.com/field-notes/inherits-rows-it-doesnt-own/): Old-style Postgres inheritance presents parent and children to information_schema as ordinary, unrelated BASE TABLEs — while a SELECT on the parent, without ONLY, also returns every child's rows. The standard migration recipe (enumerate BASE TABLEs, copy each) therefore lands the child data twice: flattened into the parent's target table and again in each child's. Silently, exit 0; the only symptom is a row count that's too big. - [2026-07-16 · The Parquet library nulled every false](https://sluicesync.com/field-notes/parquet-zero-value-null/): Hand parquet-go rows as map[string]any and it decides NULL-vs-present for optional columns by asking whether the Go value is the zero value — so false, 0, -0.0, "", the epoch, and midnight all silently export as NULL. The sharper half is why nobody notices: a Parquet NULL's accessors read back as exactly the Go zero value, so a naive write-then-read test writes false, reads false, and goes green while the file says NULL. - [2026-07-16 · CSV has no NULL](https://sluicesync.com/field-notes/csv-has-no-null/): RFC 4180 defines quoting, delimiters, and line endings, and says nothing about NULL. NULL-vs-empty is pure producer convention riding on the quoted/unquoted distinction — which Go's encoding/csv collapses. And at exactly one column wide, the universal skip-blank-lines convention is byte-indistinguishable from a legitimate record whose only field is empty. - [2026-07-16 · "mydumper format" is a family, not a spec](https://sluicesync.com/field-notes/mydumper-format-family/): pscale database dump produces "mydumper format" — same metadata file, same schema files, same ~1 MB extended-INSERT chunks, byte-compatible enough that one reader serves both. The shared layout hides three producer forks: binary travels differently, string quoting differs, and TIMESTAMP semantics hinge on a header one producer always writes and the other never does. - [2026-07-16 · Your dump already rounded your floats](https://sluicesync.com/field-notes/mydumper-float-display-rounding/): mydumper renders single-precision FLOAT through mysqld's ~6-significant-digit float-to-text formatter: 8388608 lands in the dump file as 8.38861e6, which parses back to a different float32 — while DOUBLE columns in the very same run dump at full round-trip precision. The loss is in the file, at dump time. Restore it, archive it, trust it: the low bits are already gone. - [2026-07-16 · mydumper chunk numbers are PK ranges, not a sequence](https://sluicesync.com/field-notes/mydumper-chunk-numbers-pk-ranges/): Every consumer's instinct says numbered chunk files — table.00000.sql, 00001, … — form a sequence, so a missing file is detectable as a gap. Ground truth from real mydumper: the numbers are derived from primary-key ranges. Healthy dumps have gaps, -r dumps start at 00001, and a deleted trailing chunk leaves no gap at all — so contiguity is neither necessary nor sufficient. Meanwhile a deleted middle chunk streams silently short at exit 0, and the loss detector the format actually ships is the metadata everyone skips as informational. - [2026-07-16 · The two MySQL escapes that keep their backslash](https://sluicesync.com/field-notes/mysql-like-escapes-keep-backslash/): MySQL's string-literal escape table has a trap in its last two rows: \% and \_ do not evaluate to % and _ — they evaluate to the two bytes \% and \_, backslash included. Every other unrecognized escape drops the backslash. A uniform unescaper — which is what almost every hand-rolled MySQL-literal decoder is — silently shortens data containing literal backslash-percent sequences by one byte. - [2026-07-16 · Persist a resume cursor as JSON and it silently teleports](https://sluicesync.com/field-notes/json-cursor-teleport/): A resumable keyset walk persists its last-processed PK between runs, and the obvious store is JSON — which quietly rewrites database values on the way through. We saw half the class at design time ([]byte → base64, time.Time → RFC 3339) and normalized it. The half we missed fired live: Go's json.Marshal replaces invalid-UTF-8 bytes with U+FFFD, and numbers ride float64 past 2^53 — so a resumed walk skipped 73,100 of 100,000 rows at exit 0. Resume state is a codec too, and ours had zero coverage. - [2026-07-16 · Same document, different winner](https://sluicesync.com/field-notes/same-document-different-winner/): RFC 8259 declines to define what a duplicate object key means, and two mainstream engines quietly picked opposite answers. So "this text column validated as JSON, promote it to jsonb" silently changes which value every future query reads — at exit 0, with the stored bytes looking fine in any spot check. The sharpest edge: the promotion's validator was SQLite's own json_valid, a validator that happily accepts exactly what the target type destroys. - [2026-07-16 · The alert cleared at the exact moment the slot died](https://sluicesync.com/field-notes/alert-cleared-when-slot-died/): Monitor a Postgres replication slot by WAL-retention pressure and you inherit a sign flip at the terminal event: when Postgres invalidates the slot, pg_replication_slots reports wal_status='lost' and the lag columns go NULL — not huge. Coerce NULL to zero, compute 0% pressure, and your threshold evaluator concludes the condition cleared. The operator is told the pressure resolved at precisely the moment it became fatal. - [2026-07-16 · The dump reader skipped what it couldn't lex — and the verifier rode the same reader](https://sluicesync.com/field-notes/verifier-rode-the-same-reader/): A statement-splitting dump reader dispatched on the first token and let anything that lexed empty fall through as "comment-only." A UTF-8 BOM glued to the first INSERT made it lex empty: the whole statement vanished at exit 0. And verify --depth count counted through the identical blind spot, so the safety net confirmed the loss instead of catching it. Then the fix's own third act: the refusal reached verify's report but not its exit code. - [2026-07-16 · Your dump reader is quadratic in a knob somebody else set — twice](https://sluicesync.com/field-notes/quadratic-in-a-knob-somebody-else-set/): A dump reader's cost was quadratic in statement size — and statement size isn't the reader's variable, it's the --statement-size flag chosen by whoever took the dump. We found it, fixed it, benchmarked the fix, shipped "order-of-magnitude on the giant chunk" — and the same-day regression cycle measured no end-to-end difference at all, because the same complexity class lived one layer down, in a buffer sized to the statement tail. Two acts: the quadratic, and the quadratic that survived its own fix. - [2026-07-16 · The round-trip test that cannot see symmetric bugs](https://sluicesync.com/field-notes/round-trip-cannot-see-symmetric-bugs/): Write false, the file says NULL, read it back as false — green. A writer bug whose read-back is symmetric is invisible to every round-trip test, and the general condition is worse than one bug: if your writer and every test pin read through the same library, the entire format boundary is self-consistent, and a symmetric regression ships files the rest of the world can't read while your suite stays green. The fix isn't another test; it's an outside reader. And the checker we built to be that reader promptly demonstrated the class inside its own harness. - [2026-07-16 · The index that shares only a name](https://sluicesync.com/field-notes/index-that-shares-only-a-name/): Idempotent index builds are detect-then-skip: if an index with the intended name already exists on the target, skip the build. But a name is not a definition — and index names live in a tiny convention-driven namespace (idx_email, uq_name). The sharpest cell is uniqueness: when the source's UNIQUE index name-matches a plain INDEX on the target, the existing definition silently decides which duplicate writes the target accepts or refuses. Every step exits green. - [2026-07-16 · Two things the Parquet export directory doesn't tell you](https://sluicesync.com/field-notes/parquet-directory-doesnt-tell-you/): Two things a file-based export doesn't tell its readers — and what spec-compliant readers assume in the silence. GeoParquet defines an omitted crs as "this is lon/lat degrees" — so omission is an assertion, and an EPSG:3857 export without the stamp reads Web-Mercator meters as degrees, no error, wrong planet positions. And the standard read_parquet('dir/*.parquet') recipe treats the directory as the catalog — but a re-export doesn't unwrite old files, so a dropped table's stale .parquet keeps answering the glob as current data. The fix for the second grew a third act: the first orphan sweep deleted without an ownership proof, and a cleanup pass without one is a hazard of its own. - [2026-07-16 · Your replication "position" is an unbounded set](https://sluicesync.com/field-notes/position-is-an-unbounded-set/): A MySQL GTID set grows with every server UUID that has ever written to the topology; a Vitess VGTID is that set again per shard. Checkpoint "the position" into a MySQL TEXT column and you've stored an unbounded value in a 64 KB box — and on a server without strict mode, the overflow is a silently truncated position, discovered only at the next resume. - [2026-07-16 · Postgres writes +00; your parser expects +00:00](https://sluicesync.com/field-notes/shortest-legal-offset/): ISO 8601 admits at least four spellings of a UTC offset, and Postgres COPY picks the shortest: 2026-07-15 08:09:10.123456+00. A layout list that stops at ±hh:mm refuses Postgres's own default text output. And the fix has its own trap — a bare date like 2026-07-02 ends in -02, exactly the naive two-digit offset shape. - [2026-07-16 · Object stores can now say "that changed since you read it" — the portability layer can't ask](https://sluicesync.com/field-notes/object-store-create-only-cas/): Every writer of a backup chain shared one read-modify-write JSON catalog with no arbitration — last Put wins, loser's update silently vanishes. The fix wants compare-and-swap on the object, and the major stores now have it — but the portable object-store surface exposes only create-if-absent. You can build a CAS out of create-only (since ground-truthed live on real S3, GCS, and Azure Blob — three stores that answer an occupied-key conditional write with three different status codes). You can also be honest about the millisecond window it can't close — and about the fact that the primitive that would close it exists upstream, one abstraction layer away. - [2026-07-14 · MySQL's own certificate can't pass verify-full](https://sluicesync.com/field-notes/mysql-cert-no-san/): The moment you decide to do MySQL TLS properly — tls=true, encrypt and verify the server — the handshake fails against a stock MySQL. Not because anything is misconfigured: the certificate mysqld generated for itself on first boot carries no SubjectAltName, and modern Go won't fall back to the Common Name to check the hostname. The two facts are structurally incompatible, and neither one makes that obvious on its own. - [2026-07-12 · An ALTER with no rows behind it is invisible to Postgres CDC](https://sluicesync.com/field-notes/ddl-invisible-without-rows/): Change-data-capture tools quietly assume a schema change leaves a mark in the replication stream. It does on MySQL and it does not on Postgres, and the divergence is sharp: pgoutput never streams DDL at all — a schema change surfaces only as a RelationMessage, and only right before the first row that follows it. A pure ALTER with no writes behind it produces nothing. - [2026-07-10 · A CDC position can lead or trail the rows it covers](https://sluicesync.com/field-notes/cdc-position-leads-or-trails/): A change-stream gives you a position token to resume from and to reason about order — but engines disagree, non-obviously, on whether that token sits before or after the rows it names. Postgres and MySQL put a schema/DDL position ahead of the rows it introduces; Vitess stamps its commit token after them. Any “did we reach the boundary?” check written against one engine silently false-negatives on the other. - [2026-07-09 · When the row's own identity gets rounded](https://sluicesync.com/field-notes/float-in-primary-key/): The VStream FLOAT repair re-reads the source exactly and matches rows by primary key. That works perfectly — until the FLOAT is part of the primary key. Then the target's copy of the key is itself rounded, the exact re-read never finds its row, the repair silently no-ops, and --strict-float exits 0 with a rounded archive. - [2026-07-09 · A signature that verified green while restoring the wrong table's rows](https://sluicesync.com/field-notes/signed-manifest-chunk-binding/): A signed, encrypted backup flattened every table's row chunks into one file-sorted list with no parent-table token. Swap the chunk lists of two tables that share a column set and the signed bytes are byte-identical — every guard passes, and one table's rows restore into the other. - [2026-07-09 · Three clouds, three ways to return an ECDSA signature](https://sluicesync.com/field-notes/three-clouds-three-signatures/): AWS and GCP hand back an ECDSA signature as ASN.1 DER; Azure returns raw r‖s. Only GCP signs Ed25519, and only GCP wants a CRC32C integrity handshake in both directions. Adding two clouds to a working KMS signer was not a copy-paste — it was normalizing three wire formats to one. - [2026-07-09 · Vitess copy phase rounds your FLOATs](https://sluicesync.com/field-notes/vstream-float-precision/): MySQL renders FLOAT to 6 significant digits over the text protocol, so a stored, exact 8388608 comes back as 8388610. Vitess's VStream copy phase inherits it; its binlog phase doesn't — the same column, same row, arrives exact or rounded depending on which phase delivered it. - [2026-07-08 · Comparing 32-bit transaction ids breaks after four billion of them](https://sluicesync.com/field-notes/xid-wraparound-cdc/): A trigger-CDC hold-back compared a change row's 32-bit xmin against a 64-bit xid8 snapshot bound. At XID epoch 0 the two domains coincide and everything works; once a cluster crosses 2^32 lifetime transactions the predicate goes always-true and silently skips an in-flight transaction's rows. - [2026-06-30 · Cloudflare D1 is not your local SQLite](https://sluicesync.com/field-notes/d1-not-local-sqlite/): Our type-inference validated candidate columns with SQLite GLOB patterns — a UUID check is a 356-character char-class pattern — and passed every test we had, including a multi-GB head-to-head. Then it hit live D1 and died instantly: code 7500, LIKE or GLOB pattern too complex, on a 1,750-row table with pristine data. - [2026-06-29 · When PlanetScale un-acked our rows](https://sluicesync.com/field-notes/planetscale-grow-reparent/): A 5.5M-row migrate into PlanetScale MySQL returned exit 0 and “migration complete” — and landed 5,496,003 rows. About four thousand, gone in scattered whole-batch units, no error anywhere. - [2026-06-29 · 2^53 is a database boundary now](https://sluicesync.com/field-notes/int64-json-boundary/): JSON has one number type, and it's a double. That single fact produced two independent silent-corruption incidents in one week — one in third-party tooling, one in our own decoder. - [2026-06-28 · The 20-second guillotine over a WAN](https://sluicesync.com/field-notes/vitess-tx-killer-wan/): Continuous CDC into PlanetScale MySQL over the internet stalled at effectively zero throughput. The failure geometry: with no statement pipelining an N-row apply costs N round-trips; at 96 ms RTT a 1,000-row batch takes ~100 seconds; Vitess kills any transaction at 20 seconds; the adaptive batch controller shrinks the batch — and converges to a stall. - [2026-06-28 · One long-lived reader, 75 GB of WAL](https://sluicesync.com/field-notes/sqlite-wal-checkpoint-starvation/): A continuous-CDC run against a 20 GB SQLite source watched the -wal file grow from zero to 75 GB in 52 minutes — while the change-log table it tracked stayed bounded at a few thousand rows. In WAL mode, a checkpoint can only reclaim frames older than the oldest live reader's snapshot. - [2026-06-27 · SQLite's DECIMAL is a suggestion](https://sluicesync.com/field-notes/sqlite-decimal-affinity/): SQLite doesn't have column types; it has affinities. Declare DECIMAL(10,2) and you get NUMERIC affinity, which stores any non-integer as a float64 — so 19.99 lands as 19.989999999999998 on disk. Not a rounding bug: an engine storage property. - [2026-06-22 · A poller that re-reads all of history every tick](https://sluicesync.com/field-notes/poll-cost-grows-with-history/): A backup broker rebuilt its entire lineage chain on every 30-second tick — one object-store GET per manifest, even when nothing had changed. On a week-old stream that's ~2,000 GETs a tick, forever, with a tick that could outlast its own interval. The cost was tied to the age of the stream, not the size of the change. - [2026-06-20 · ENUM is an ordinal and SET is a bitmask on the wire](https://sluicesync.com/field-notes/mysql-enum-set-binlog-encoding/): In a raw binlog row event a MySQL ENUM cell is its 1-based ordinal and a SET cell is a numeric bitmask; the member-name list lives only in the table definition, never in the event. Decode without the schema and SET('a','c') becomes "5". Snapshot and VStream hand you text, so it hides until raw CDC. - [2026-06-18 · Your primary key is only unique per shard](https://sluicesync.com/field-notes/vitess-per-shard-primary-key/): vtgate merges every Vitess/PlanetScale shard into one logical stream, but per-shard id ranges mean the same primary-key value legitimately exists on several shards. Copy them into one target table with that key and the collisions silently overwrite — exit 0, rows short. - [2026-06-17 · A whole transaction in one zstd binlog event](https://sluicesync.com/field-notes/binlog-transaction-compression/): MySQL 8.0.20+ can pack an entire transaction into a single compressed TRANSACTION_PAYLOAD_EVENT. A binlog reader without a handler for it applies nothing and freezes its position with no error — and the server zeroes the inner events' end_log_pos, so a naive resume restarts mid-payload and dies. - [2026-06-15 · The zero value is a loaded gun](https://sluicesync.com/field-notes/zero-value-config-trap/): Twice in this project a config field that “defaults on” silently defaulted off (or worse) for every caller that didn't go through the CLI — because in Go, every construction site that doesn't set a field gets the zero value. Both had real database consequences. - [2026-06-13 · vtgate erases the throttle signal](https://sluicesync.com/field-notes/vstream-throttle-blind/): Our stream went silent under a write burst, a progress watchdog called it a failover hang, the process restarted, resumed at the same stuck position, and stalled again — indefinitely. The one in-band signal that would have said “this is a throttle, wait” is deleted before any client can see it. - [2026-06-13 · A comment hid a TRUNCATE from CDC](https://sluicesync.com/field-notes/binlog-comment-truncate/): A leading -- comment on a TRUNCATE made our CDC reader miss the statement entirely. The source emptied; the target kept every row, forever, with no error and no lag. - [2026-06-11 · Replication slots don't die with your process](https://sluicesync.com/field-notes/postgres-slot-leaks/): A Postgres logical replication slot is a promise the server keeps: it retains WAL from the slot's restart_lsn until you drop it — even if the process that created it crashed weeks ago. We hit the class three separate ways, each invisible until the source disk fills. - [2026-06-11 · Rewriting the whole manifest, once per chunk](https://sluicesync.com/field-notes/backup-manifest-quadratic/): Every backup checkpoint re-wrote the entire manifest.json, schema included. Since the manifest grows with table count, the total was quadratic — a measured ~78 hours of pure manifest rewriting at 100k tables. And the two obvious ways to fix it are the same quadratic in disguise. - [2026-06-10 · One JSON blob in one row is a quadratic write](https://sluicesync.com/field-notes/migrate-state-quadratic-blob/): Storing all per-table progress as a single growing JSON blob and re-upserting it on every checkpoint is O(n²) work. On Postgres the amplification lands somewhere specific: a new tuple version plus a re-TOAST of the whole value, every time, on one hot row — while the clone runs inside the lock your workers are waiting on. - [2026-06-09 · The cold-start that buffered a whole table into swap](https://sluicesync.com/field-notes/vstream-snapshot-oom/): A 13 GB PlanetScale table drove the process to ~41 GB of RAM and got OOM-killed with zero rows written — the VStream snapshot reader held the entire copy phase in memory before a single row reached the target. The buffer wasn't laziness; three engine behaviors forced it. - [2026-06-08 · BIGINT UNSIGNED overflows both bigint and int64](https://sluicesync.com/field-notes/bigint-unsigned-uint64/): A MySQL BIGINT UNSIGNED reaches 2⁶⁴−1, past Postgres bigint's 2⁶³−1 — and past Go's int64, so above that boundary the driver hands the value back as a uint64 a []byte/string-only decoder can't route. The type mismatch is known; the driver-representation switch is the sharp edge. - [2026-06-07 · Setting workload=olap silently truncated our chunked reads](https://sluicesync.com/field-notes/olap-workload-truncation/): A one-line change set vtgate's workload=olap session-wide to lift a 100k-row cap on no-PK scans. The parallel chunked reader inherited the setting, each concurrent chunk streamed only a prefix, and a 1.5M-row migrate copied 7,536 rows — exit 0, migration complete. - [2026-06-03 · The transaction that lands in neither the snapshot nor the binlog](https://sluicesync.com/field-notes/snapshot-position-gap/): Capture the consistent snapshot and the binlog position as two separate statements, and a transaction committing between them falls into the gap: after the frozen read view, below the recorded offset. It's in neither the bulk copy nor the CDC tail. A global read lock across both closes the seam. - [2026-06-02 · Postgres text can't hold a NUL byte](https://sluicesync.com/field-notes/postgres-text-no-nul-byte/): text, varchar, and char reject an embedded 0x00 with SQLSTATE 22021; MySQL char/text store it without complaint. A cross-engine copy hits it, and because it fires inside the COPY protocol the error lands far from the offending row. - [2026-05-31 · MySQL TIME is a duration, not a time of day](https://sluicesync.com/field-notes/mysql-time-is-a-duration/): A MySQL TIME column ranges -838:59:59 to 838:59:59 and models elapsed duration, not clock time. Postgres time is a time-of-day, 00:00 to 24:00 — so any negative or over-24-hour MySQL TIME has no home there. The faithful target is interval. - [2026-05-30 · MySQL turned our emoji into '?'](https://sluicesync.com/field-notes/mysql-enum-emoji/): A MySQL ENUM whose label contains an emoji doesn't contain that emoji by the time you read it back. MySQL substitutes ? for 4-byte UTF-8 characters in ENUM/SET labels at CREATE TABLE time, regardless of column charset — and the label is gone from the catalog before any client sees it. - [2026-05-30 · One redaction flag, two engines, two behaviors](https://sluicesync.com/field-notes/redact-two-engines/): --redact randomize:int:100000,200000 into a SMALLINT column loud-refused on a Postgres target and silently clamped every row to 32767 on a MySQL one — turning an anonymization rule into a constant, and a compliance guarantee into a compliance failure. - [2026-05-28 · REPLICA IDENTITY FULL ate our UPDATEs](https://sluicesync.com/field-notes/replica-identity-full-updates/): Build a CDC UPDATE's WHERE clause over every old column and it works forever on int and varchar. Then a jsonb column rides along unchanged, its old value fails the equality round-trip, the UPDATE matches zero rows, and idempotency tolerance swallows the miss. - [2026-05-25 · The replication stream never tells you the column default](https://sluicesync.com/field-notes/cdc-carries-no-default/): Neither pgoutput nor the MySQL binlog carries a column's DEFAULT. Forward an ADD COLUMN … DEFAULT now() over CDC and the target re-evaluates the default on its own — so every row that shipped before the ALTER gets a different value than the source's backfill. - [2026-05-24 · CREATE IF NOT EXISTS is not a lock](https://sluicesync.com/field-notes/create-if-not-exists-race/): CREATE TABLE / TYPE … IF NOT EXISTS does a catalog pre-check and then an insert, and those two steps aren't atomic against a concurrent creation of the same name. Race it and one side gets a unique_violation on pg_class — from the statement that reads like it can't fail. - [2026-05-23 · proto_version lets you parse streaming; only streaming='on' emits it](https://sluicesync.com/field-notes/pgoutput-streaming-abort/): Two pgoutput knobs are easy to conflate. The receiver flag equips you to parse streamed transactions; a separate publisher flag makes the server actually send them. The gap between them hides a silent-loss shape: a dropped StreamAbort leaves already-committed chunks on the target. - [2026-05-22 · A Postgres LSN means nothing without its timeline](https://sluicesync.com/field-notes/postgres-lsn-timeline-scoped/): A logical-replication LSN is only comparable within a (system_id, timeline) tuple. Resume after a PITR or a promotion and the same slot name and same stored LSN point into a different WAL reference frame — the source streams from it happily, and events are silently skipped or replayed. - [2026-05-20 · BIT crosses the wire as bytes, and the engines disagree on layout](https://sluicesync.com/field-notes/mysql-bit-wire-bytes/): MySQL hands BIT(N) back as ceil(N/8) right-justified big-endian bytes; Postgres surfaces bit as a '0'/'1' text string. Carry the raw bytes between them through one []byte path and you silently store the ASCII of the digits, not the bits. - [2026-05-17 · The pgx codec that flattened numeric[][]](https://sluicesync.com/field-notes/numeric-array-flatten/): We pinned multi-dimensional array support green on int[][] and text[][] and shipped. numeric[][] — running through byte-identical code — flattened a 2×2 matrix into a 1-D four-element array. Exit 0, no warning. - [2026-05-15 · Count your bytes, not your rows](https://sluicesync.com/field-notes/batch-by-bytes-not-rows/): A batch size tuned for narrow OLTP rows — 5,000 rows, under 10 MB — quietly pins hundreds of MB the moment the workload is MB-scale TEXT, BYTEA, JSON, or geometry. Row count is a proxy for memory, and it's only honest when rows are uniform. - [2026-05-10 · {}: two characters, two types](https://sluicesync.com/field-notes/empty-object-vs-array/): In Postgres, {} is an empty array literal. In JSON, it's an empty object. Funnel both through one value-preparation path and []byte("{}") is genuinely ambiguous — and for nine releases our MySQL writer resolved it the wrong way. - [2026-05-08 · One INSERT is three binlog events (or four)](https://sluicesync.com/field-notes/binlog-event-volume/): The binlog is a log of events, not row changes. A single-row INSERT lands as three (BEGIN / WRITE_ROWS / XID), plus a spurious empty BEGIN/COMMIT per new connection — so if you size a rollover bound by INSERT count, budget 4×. Postgres counts differently again. - [2026-05-07 · Every HA knob on, and the slot still vanished at failover](https://sluicesync.com/field-notes/postgres-idle-slot-failover/): Patroni slot-sync on, sync_replication_slots on, hot_standby_feedback on — and a logical slot that hadn't advanced during the sync window was still lost on promotion. “HA-replicated” means the slot's LSN is copied on a timer, not that the slot can't be lost. - [2026-05-05 · parseTime governs the query protocol, not the binlog](https://sluicesync.com/field-notes/binlog-temporal-strings/): parseTime=true on the DSN makes the query driver return time.Time. But the replication stream is a different code path, and it hands temporal columns back as raw strings regardless. The first TIMESTAMP row killed the CDC pump — and the silent channel-close looked exactly like a network stall for two release cycles. - [2026-05-05 · One LOAD DATA can't load a BLOB and a JSON column](https://sluicesync.com/field-notes/mysql-load-data-charset/): A BLOB column needs CHARACTER SET binary or the server rejects its first non-ASCII byte; a JSON column rejects its input under CHARACTER SET binary. The two requirements point opposite ways, and there is no statement-level clause that satisfies both. - [2026-05-04 · MySQL won't match a JSON column by bind parameter](https://sluicesync.com/field-notes/mysql-json-where-cast/): WHERE json_col = ? matches zero rows in MySQL whether you bind the value as a string or as bytes — the server won't cast the parameter to JSON for the comparison. On a CDC UPDATE, replay-idempotency tolerance turns that zero-row match into silent divergence. ## Source-repo markdown (best for LLMs) - [README](https://raw.githubusercontent.com/sluicesync/sluice/main/README.md): project overview, quick start, engine list, CLI command table - [Architecture](https://raw.githubusercontent.com/sluicesync/sluice/main/docs/architecture.md): the typed IR, engine pattern, orchestrator, capability model - [Type mapping](https://raw.githubusercontent.com/sluicesync/sluice/main/docs/type-mapping.md): cross-engine type translation policies, extension types, override tokens - [Value types](https://raw.githubusercontent.com/sluicesync/sluice/main/docs/value-types.md): the runtime row-value contract (what each IR type holds at copy time) - [Cookbook](https://github.com/sluicesync/sluice/tree/main/docs/cookbook): task-shaped recipes (one-shot migrate, zero-downtime cutover, encrypted backup chains, PII redaction) - [ADR index](https://raw.githubusercontent.com/sluicesync/sluice/main/docs/adr/README.md): every architecture decision record with a one-line summary - [Security policy](https://raw.githubusercontent.com/sluicesync/sluice/main/SECURITY.md): threat model, the source-trust boundary, credential handling, reporting ## Optional - [Full documentation as one plain-text file](https://sluicesync.com/llms-full.txt) - [GitHub repository](https://github.com/sluicesync/sluice) - [Release notes archive](https://github.com/sluicesync/sluice/tree/main/docs/releases)