
MySQL tables reach Snowflake in three basic ways: mysql client dumps loaded with COPY INTO, scheduled updated_at queries, or change data capture (CDC) that reads the binary log (binlog) like a replica. For a production database CDC usually fits best: it reads each committed insert, update and delete as a row event instead of re-querying tables, so it also sees hard deletes.
At the source, MySQL keeps binlog files only for a retention period each managed host sets differently, and a pipeline that falls behind it must re-read every table. In Snowflake, DECIMAL loaded as FLOAT keeps about 15 digits, and every load resumes a billed warehouse. Estuary runs this route as a managed service: it reads the MySQL binlog into a durable collection per table and loads Snowflake at an interval you choose.
Choosing a MySQL to Snowflake method
| Approach | Best fit | Freshness | Operational burden | Example implementation |
|---|---|---|---|---|
| One-time export | Leaving MySQL, or one snapshot | As of the export | Low, but manual | mysql --batch TSV → stage → COPY INTO |
| Scheduled incremental query | Small tables, no binlog access | At the schedule | Medium: scheduler and MERGE SQL | Cron or Airflow running updated_at queries |
| Snowflake's binlog connector | Snowflake as the only target; password auth acceptable | Ongoing or scheduled | Medium: a single-node Openflow runtime | Openflow Connector for MySQL |
| Debezium on Kafka Connect | A Kafka Connect cluster already runs | Ongoing, as appended rows | High: snapshot locks, topics, your own dedup/MERGE | Debezium MySQL connector + Snowflake Kafka connector |
| Managed binlog CDC | Production MySQL without pipeline code to maintain | Binlog read nonstop, Snowflake on a schedule | Low: settings only | Estuary |
Openflow is Snowflake's current MySQL connector; the older native-app connector stays in preview, with general availability off the roadmap. Most of what follows assumes a binlog reader.
How MySQL to Snowflake binlog CDC works
MySQL sends a CDC pipeline the same row events a replica would apply. Four details matter:
binlog_format = ROWlogs each changed row.STATEMENT, andMIXEDby default, log SQL text that a CDC reader can't turn back into rows.binlog_row_image = FULL(default) logs every column;MINIMALlogs only the key before a change and changed columns after.binlog_row_metadata = FULLadds column names,ENUM/SETlabels and key columns. At the default,MINIMAL, the reader tracks structure by parsing DDL, so DDL missing from the binlog leaves it wrong.- The position is a file and byte offset, valid only on the server that wrote it, or a GTID, valid across the topology.
A new pipeline copies existing rows with SELECT queries and replays binlog events from where the copy began, so a row changed during the copy isn't lost or applied out of order. In Snowflake, changes are staged and merged by key into current-state tables, or appended as rows (Snowpipe Streaming, the Kafka connector).
MySQL requirements
Settings differ by host:
| Where MySQL runs | Row-based binlog | Retention setting | Watch out for |
|---|---|---|---|
| Self-hosted 8.0 / 8.4 | On by default, format ROW | binlog_expire_logs_seconds, default 30 days | time_zone is SYSTEM by default |
| Amazon RDS for MySQL | On while automated backups are on; set ROW in a custom parameter group | CALL mysql.rds_set_configuration('binlog retention hours', 168); Default NULL keeps none; 168 is the maximum | Retained binlogs use instance storage |
| Amazon Aurora MySQL | ROW in a custom DB cluster parameter group; reboot the writer, and readers on 2.10+ | Same call. NULL cleans up lazily, usually within a day; maximum 2,160 hours on 2.11+ and version 3 | Only the writer has binlogs |
| Google Cloud SQL | On with point-in-time recovery; fixed at ROW | binlog_expire_logs_seconds flag, default 1 day | The lesser of the flag and transaction log retention (1–7 days on Enterprise edition) applies |
| Azure Database for MySQL Flexible Server | Always on, always ROW | binlog_expire_logs_seconds, default 0 | 0 purges files once unneeded; accelerated logs ignore it |
On self-hosted MySQL 8.x, check the settings and create a least-privilege user:
sqlSELECT @@log_bin, @@binlog_format, @@binlog_row_image, @@binlog_row_metadata,
@@binlog_expire_logs_seconds, @@time_zone;
SET PERSIST binlog_row_metadata = 'FULL';
CREATE USER 'cdc_user'@'%' IDENTIFIED BY '...';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'cdc_user'@'%';
GRANT SELECT ON shop.* TO 'cdc_user'@'%'; -- schema or table level, not column levelThree rules prevent most incidents:
- Size retention for your longest outage plus catch-up time, with seven days as a floor (the RDS for MySQL maximum).
- Give every table a primary key or a
NOT NULLunique index. SnowflakeMERGEmatches on it. - Name the time zone.
DATETIMEstores no zone andtime_zone = SYSTEMnames none, so a reader must be told which zone to convert from. Set the server's zone (IANA names need the time zone tables), or tell the pipeline.
Snowflake requirements
On the Snowflake side, create a database, schema and warehouse plus a dedicated role and service user, and give that user a key pair: Snowflake is disallowing passwords for service users.
AUTO_SUSPEND = 60keeps idle time short; each resume is still billed for at least 60 seconds, then per second.- Choose the timestamp type deliberately; an account-level
TIMESTAMP_TYPE_MAPPINGmust match what the pipeline writes. - Don't enable
QUOTED_IDENTIFIERS_IGNORE_CASEfor the pipeline user; MySQL columns named after Snowflake reserved words are quoted.
Setting up MySQL to Snowflake replication
- Turn on row-based logging with full row images and metadata, and confirm with the query above.
- Set binlog retention for your worst outage, with your host's setting.
- Create the replication user and let the pipeline reach the MySQL port: allowlisted IPs, an SSH bastion, or private networking.
- Prepare Snowflake as described above.
- Pick merge or append per table:
MERGEfor tables read as current state, append for event tables. - Load existing data. Retention must cover the whole backfill too; prioritise tables and skip unneeded history.
- Start streaming the binlog, with a Snowflake load interval matched to how often tables are read.
- Validate. Compare row counts and the exact
SUMof eachDECIMALcolumn.UPDATEandDELETEa test row and add a column, then check each in Snowflake after one load interval. Pause the pipeline, runFLUSH BINARY LOGSto start a new binlog file, and confirm it resumes from its saved position.
How updates, deletes and schema changes reach Snowflake
- Updates merge by key, or append as a new version. A primary-key update usually arrives as a delete of the old key plus an insert of the new one.
- Deletes carry only the row's before image; pipelines remove the Snowflake row or flag it, as Openflow's
_SNOWFLAKE_DELETEDdoes. ON DELETE CASCADEchild deletes run inside InnoDB on MySQL 8.0 and 8.4 and aren't logged, so binlog CDC never sees them; 9.6 and later log them as row events unlessinnodb_native_foreign_keysis on.TRUNCATE TABLEis always logged as a statement, so binlog CDC tools typically ignore it. Reload if Snowflake must match.- New columns appear as nullable columns; earlier rows stay
NULLuntil re-loaded. - Type changes that widen, such as
INTtoBIGINT, pass through or migrate in place; incompatible ones usually force a table rebuild. ALTER TABLE … DROP COLUMNstops new values for that Snowflake column; Openflow also renames it with a__SNOWFLAKE_DELETEDsuffix.
MySQL to Snowflake data type mapping
| MySQL | Snowflake target | Notes |
|---|---|---|
TINYINT … BIGINT, signed or unsigned; YEAR | NUMBER(38,0) | Lossless |
TINYINT(1) / BOOLEAN | BOOLEAN or NUMBER | An alias for TINYINT(1), so the binlog carries integers; col::BOOLEAN treats 0 as false |
DECIMAL(p,s), p ≤ 38 | NUMBER(p,s) | FLOAT keeps about 15 digits |
DECIMAL(p,s), p > 38 | VARCHAR | MySQL allows 65 digits; Snowflake NUMBER holds 38 |
FLOAT, DOUBLE | FLOAT | |
CHAR, VARCHAR, TEXT, ENUM, SET | VARCHAR | ENUM as its label, SET comma-separated |
JSON | VARIANT | Query paths with : |
BINARY, VARBINARY, BLOB | BINARY | |
DATE | DATE | Zero dates (0000-00-00) can exist where NO_ZERO_DATE was off; pipelines substitute 0001-01-01, 1970-01-01 or NULL |
DATETIME | TIMESTAMP_NTZ or TIMESTAMP_TZ | No zone stored; interpret in the server's time_zone |
TIMESTAMP | TIMESTAMP_TZ or TIMESTAMP_LTZ | Stored as UTC; range ends 2038-01-19 |
DECIMAL precision
A DECIMAL(18,4) amount has more digits than a 64-bit double holds (about 15), so a pipeline mapping it to Snowflake FLOAT instead of NUMBER(p,s) rounds it and sums stop matching MySQL. Declare NUMBER(p,s) with the MySQL precision and scale up to 38 digits, and VARCHAR above that. Casting the FLOAT column afterwards can't bring back rounded digits; reload the table.
Performance, latency and Snowflake cost
The binlog stream never stops, but a Snowflake table is only as fresh as its last load. Cost tracks how often loads wake the warehouse and how many micro-partitions each MERGE touches.
- Load less often. Each warehouse resume bills at least 60 seconds, so a load every minute keeps it billed around the clock.
- Merge each key once per load. A MySQL counter bumped 500 times between loads should become one merged row, not 500.
- Keep keys in write order. A
MERGEbounded by the load's key range lets Snowflake skip micro-partitions: newAUTO_INCREMENTrows sit in a narrow range, random UUIDs span the table. - Append event tables. Snowpipe Streaming inserts without reading the table, but updates become extra rows, so current state needs a view, which costs compute.
Production considerations
- Retries and recovery. A restarted pipeline resumes from the binlog file and offset it last committed, if MySQL still retains that file. Replays are harmless in merged tables but duplicate appended rows unless position and write commit atomically.
- Source impact. Streaming adds one replication connection; backfills add key-range scans; batch methods re-query every run.
- Monitoring. Alert on lag against binlog retention (
SHOW BINARY LOGSlists the files MySQL still holds), on free storage, and on time since the last Snowflake load.
Common problems and troubleshooting
Binlog purged before the pipeline caught up
The pipeline can't resume and reports ERROR 1236 with "Could not find first log file name". The purged changes are gone. Compare the pipeline's saved file with the oldest in SHOW BINARY LOGS.
Recovery, cheapest first:
- Large tables are append-only or have reliable change timestamps: restart at the current position and re-read only rows newer than the outage there (
id > …orupdated_at > …), the rest in full. - Otherwise: restart at the current position and re-read every table, merging into Snowflake without truncating so it stays queryable.
A re-read can't show rows deleted in the gap: reconcile keys or reload those tables, then raise retention.
Position lost after a failover, restore or replica rebuild
A file-and-offset position means nothing on another server, so after a promotion, snapshot restore or replica rebuild the pipeline fails with ERROR 1236, sometimes citing a position past the end of the file. For a planned switch, pause writes, let the pipeline catch up, switch, then restart at the new server's current position without re-reading tables. Treat an unplanned one like a purged binlog; tools that track GTIDs can follow a failover.
Pipeline falling behind the binlog
If the reader is current but Snowflake is stale, look at the load interval or merges. Real binlog lag usually comes from huge transactions (under FULL, a bulk UPDATE logs two images per row) or a competing backfill; an event over max_allowed_packet stops replication until you raise it.
How Estuary's MySQL to Snowflake pipeline works
Estuary's MySQL CDC capture reads the binlog while backfilling each table in key-ordered chunks of 50,000 rows, and its Snowflake materialization combines changes to each key between syncs, stages them in an internal Snowflake stage, then runs COPY INTO, or MERGE when some keys already exist.
- Retention: at startup the capture requires
log_bin,ROWformat, theREPLICATION CLIENTgrant and 168 hours of retention. If its user can readmysql.rds_configuration, an RDSbinlog retention hoursofNULLfails with "binlog retention period is too short". Otherwise it readsbinlog_expire_logs_seconds, thenexpire_logs_days, counting zero in both as no expiry, though Azure's default0purges promptly. It can't see Cloud SQL's transaction log retention. - Types:
DECIMALarrives as an exact string withformat: number, which lands asFLOATby default. SetcastToString: trueon the field to keep the exact digits asTEXT, then cast toNUMBER(p,s)in a view; aDDLoverride alone still converts throughFLOAT.TINYINT(1)stays an integer unless Estuary support sets a feature flag. Binary lands asBINARY, zero dates as0001-01-01, andTIMEasTEXT.DATETIMEis converted to UTC usingtime_zoneor the capture's Timezone. - Keyless tables use a
NOT NULLunique index if there is one. Otherwise the collection is keyed by binlog position, which is unique to each change, so Snowflake gets a row per change rather than current state. - Schema changes:
ALTER TABLEis applied from the binlog.DROP TABLEandRENAME TABLEdeactivate the binding, and a recreated table is backfilled again.TRUNCATEis ignored.
Implementing MySQL to Snowflake with Estuary
These steps adapt Estuary's general data flow guide to this route.
- Complete the prerequisites in MySQL requirements and Snowflake requirements on MySQL 8.0 or later, with at least 7 days of binlog retention (setup by host). In Snowflake, run the connector's setup script and register a public key for key-pair authentication; password auth is deprecated.
- Create the MySQL capture. On the Sources page, click New Capture, choose MySQL, name the capture, and fill in the endpoint properties:
- Server Address:
host:port. Allowlist Estuary's IP addresses, or use Network Tunnel for an SSH tunnel. Private and BYOC deployments can use private networking. - Login Username: defaults to
flow_capture; with the SQL above, entercdc_user. - Authentication: Password (32 characters at most), or AWS IAM, Google Cloud IAM or Azure IAM (IAM authentication).
- Timezone: only if
time_zoneisSYSTEMand tables haveDATETIMEcolumns. - Advanced Options: leave Skip Binlog Retention Sanity Check off; set SSL Mode to
verify_identityto check the server certificate (TLS).
- Server Address:
- Select tables. Click Next. Estuary lists one binding per discovered table under Output Collections.
- Remove tables you don't want; each binding writes its own collection.
- Raise Backfill Priority to load key tables first. Leave Backfill Mode at its default; keyless tables get Without Primary Key automatically.
- Keep Automatically keep schemas up to date on (see schema evolution).
Click Save and publish. The backfill starts, with binlog capture running alongside it.
- Create the Snowflake materialization. On the capture's details page, click Materialize, choose Snowflake, and fill in the endpoint properties:
- Host (Account URL):
orgname-accountname.snowflakecomputing.com, no protocol. - Database, Schema, Warehouse and Role from the setup script.
- Snowflake Timestamp Type: the type you chose above (timestamp mapping).
- Authentication: Private Key (JWT), with User and Private Key.
- Hard Delete: on to delete rows; off keeps soft deletes (
_meta/op = 'd').
- Host (Account URL):
- Choose the sync schedule. Under Sync Schedule, Sync Frequency defaults to 30 minutes. Shorter intervals give fresher tables and more warehouse resumes. For business-hours freshness, set Timezone, Fast Sync Start Time, Fast Sync Stop Time and optionally Fast Sync Enabled Days; outside that window, syncs run every 4 hours. See sync schedules.
- Map collections to tables. Click Next. For the naming convention, Match source structure turns each MySQL database into a Snowflake schema. Each binding under Source Collections has:
- The table name and Alternative Schema, to override names.
- Delta Updates, for append-only tables; with key-pair auth they use Snowpipe Streaming, still on the sync schedule.
- Clustering Keys, for large, selectively filtered tables; clustering bills its own credits.
Before publishing, add castToString: true for each DECIMAL field under the binding's fields.include in the specification (custom column types).
- Publish. Click Save and publish. Estuary creates the Snowflake tables and loads the backfill without waiting for the schedule.
- Verify that data is landing.
- On the capture details page, Connector Status shows backfill progress and the Shard Information card reads PRIMARY; open a collection from the Collections list to see its Data Preview.
- The materialization details page charts Data Read.
- In Snowflake, query a target table, then run the checks from Setting up MySQL to Snowflake replication.
How Estuary handles binlog loss, re-syncs and extra destinations
Backfill SELECT results and binlog row events for a MySQL table both land in one collection, a log of JSON files in your cloud storage bucket. Snowflake loads from that log, so rebuilding a table or adding a second destination never queries MySQL; capture-side events, such as a purged binlog or a recreated table, do.
One collection for the backfill and the binlog stream
DECIMAL values sit in the collection as the exact strings MySQL returned, so after you add castToString, a materialization backfill restores exact values from the collection alone. Each MERGE or COPY INTO is saved in the materialization's checkpoint before it runs, so a crash re-runs it from the same staged files.
What a re-backfill does and doesn't avoid
| Situation | Re-reads MySQL? | What to do |
|---|---|---|
Snowflake table dropped, or DECIMAL columns rounded by the FLOAT default | No | Materialization backfill from the collection |
| Planned failover or upgrade, writes paused | Binlog position only | Incremental backfill of all bindings in Only Changes mode; change Server Address in the same edit if the host changed |
| Binlog purged; some tables append-only or timestamped | Filtered rows there, all rows elsewhere | Incremental backfill with an Additional Backfill Filter on those bindings |
ERROR 1236 after a purge, no filter column | Every row | Incremental backfill of all bindings, merged into existing tables |
The limits: replay depends on retention (trial storage keeps about 20 days; use your own bucket for full history). A binlog gap means reading every table, because the capture keeps one position for all. Backfills emit no deletes, so rows deleted in the gap stay in Snowflake, and delta-update tables get duplicates from incremental backfills.
Schema evolution
ALTER TABLE changes are read from the binlog, and with Automatically keep schemas up to date on, an added MySQL column appears in Snowflake as a nullable column. Some widening changes, such as integer to text, migrate in place; other incompatible changes rebuild the table from the collection unless onIncompatibleSchemaChange says to abort or disable.
When another approach may make more sense
- Snowflake-only, native tooling: the Openflow Connector for MySQL reads the binlog from MySQL 8.0 and 8.4 (Aurora version 3 only) on a Snowflake-hosted or AWS-hosted Openflow runtime. It uses password auth only, needs a primary key,
NOT NULLunique index or logical key, marks deletes in_SNOWFLAKE_DELETEDand keeps journal tables indefinitely; re-adding a failed table re-snapshots it. - A Debezium MySQL connector already feeds Kafka: load the topics with the Snowflake Kafka connector. It inserts rows, so you build the dedup/
MERGElayer yourself. - A one-off move, or a few small tables refreshed nightly:
mysql --batchexports withCOPY INTO, or anupdated_atquery, need no binlog settings. Each run rereads or requeries every table, and deleted rows never arrive. - No binlog access (some managed tiers, or a replica without binary logging) or you need views and custom SQL: use query-based batch capture, which mostly can't see deletes. Estuary's MySQL Batch connector polls tables, views or custom SQL, every 24 hours by default.
Related resources
- Estuary MySQL CDC connector, Amazon RDS for MySQL, Google Cloud SQL for MySQL, MySQL Batch
- Estuary Snowflake materialization, sync schedules, backfilling data, schema evolution, custom column types
- MySQL: binary logging options, InnoDB and replication
- Hosts: RDS binlog retention, Aurora binlog retention, Cloud SQL transaction log retention, Azure Flexible Server parameters
- Snowflake: Openflow Connector for MySQL, numeric types, key-pair authentication
To try the route, create a MySQL capture in the Estuary dashboard and follow the data flow guide. Pricing: estuary.dev/pricing.
FAQs
Does binlog CDC lock MySQL tables during the initial load?
How do I replicate a schema-per-tenant MySQL database into Snowflake?

About the author
Seán Whelan is a Solutions Engineer at Estuary, helping customers design, troubleshoot, and run production data pipelines. Seán works across change data capture, database replication, and SaaS integrations, and writes about what actually comes up when real teams move data.



















