<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Quayen]]></title><description><![CDATA[Quayen]]></description><link>https://quayen.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:04:40 GMT</lastBuildDate><atom:link href="https://quayen.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Streaming MySQL to BigQuery with the binlog: how CDC works, and the parts that bite]]></title><description><![CDATA[Most teams start syncing MySQL to BigQuery with a nightly dump or a query like SELECT * FROM orders WHERE updated_at > :last_run. It works until someone…]]></description><link>https://quayen.hashnode.dev/streaming-mysql-to-bigquery-with-the-binlog-how-cdc-works-and-the-parts-that-bite</link><guid isPermaLink="true">https://quayen.hashnode.dev/streaming-mysql-to-bigquery-with-the-binlog-how-cdc-works-and-the-parts-that-bite</guid><category><![CDATA[bigquery]]></category><category><![CDATA[MySQL]]></category><category><![CDATA[dataengineering]]></category><dc:creator><![CDATA[Quayen]]></dc:creator><pubDate>Mon, 09 Nov 2026 08:20:16 GMT</pubDate><content:encoded><![CDATA[<hr />
<p>Most teams start syncing MySQL to BigQuery with a nightly dump or a query like <code>SELECT * FROM orders WHERE updated_at &gt; :last_run</code>. It works until someone asks why deleted orders still show up in the dashboard, or why yesterday's numbers changed. This post walks through how log-based change data capture (CDC) from MySQL to BigQuery works end to end, and the details that decide whether it's correct and affordable.</p>
<p><em>Disclosure: I build</em> <a href="https://quayen.com"><em>Quayen</em></a><em>, a managed MySQL to BigQuery CDC service. Everything here applies whether you build this yourself with Debezium or use any managed tool.</em></p>
<h2>Why polling isn't enough</h2>
<p>Query-based syncs on an <code>updated_at</code> column have three structural problems:</p>
<ul>
<li><strong>Deletes are invisible.</strong> A deleted row has no <code>updated_at</code>; it's simply gone, so the query never returns it and BigQuery keeps it forever.</li>
<li><strong>Not every write touches</strong> <code>updated_at</code><strong>.</strong> Bulk updates, migrations and some ORMs skip it.</li>
<li><strong>They load production.</strong> Every run scans an index on your busiest tables.</li>
</ul>
<p>MySQL already records every committed change, including deletes, in its <strong>binary log</strong> so replicas can follow along. Reading that log is what log-based CDC does.</p>
<h2>Step 1: configure the binlog</h2>
<p>CDC needs three settings, plus enough retention:</p>
<pre><code class="language-sql">SHOW GLOBAL VARIABLES WHERE Variable_name IN
  ('log_bin', 'binlog_format', 'binlog_row_image', 'binlog_expire_logs_seconds');
</code></pre>
<ul>
<li><code>log_bin = ON</code>. On RDS this means automated backups must be on; on Cloud SQL it's point-in-time recovery.</li>
<li><code>binlog_format = ROW</code>. <code>STATEMENT</code> logs the SQL text, not the resulting row values, so a CDC reader can't know what changed. <code>MIXED</code> falls back to statements most of the time.</li>
<li><code>binlog_row_image = FULL</code>. <code>MINIMAL</code> logs only changed columns, so your BigQuery rows end up with holes.</li>
<li><strong>Retention of at least 3 days.</strong> If the pipeline stops and MySQL purges logs it hasn't read, you get <code>error 1236: Could not find first log file name in binary log index file</code> and have to re-copy everything. On RDS and Aurora, retention is <code>CALL mysql.rds_set_configuration('binlog retention hours', 72);</code>, not <code>binlog_expire_logs_seconds</code>.</li>
</ul>
<p>The replication user needs <code>SELECT</code> on your schema plus <code>REPLICATION SLAVE</code> and <code>REPLICATION CLIENT</code> on <code>*.*</code>. Nothing else. (If you want to check a server quickly, I made a <a href="https://quayen.com/tools/mysql-binlog-cdc-checker">free binlog checker</a> that reads the output of the query above.)</p>
<h2>Step 2: snapshot, then stream from the exact position</h2>
<p>Existing rows aren't in the binlog (it's been purged long ago), so a pipeline starts with a snapshot:</p>
<ol>
<li>Open a consistent snapshot (<code>START TRANSACTION WITH CONSISTENT SNAPSHOT</code>) and record the binlog position (file + offset, or GTID set) at that same moment.</li>
<li>Copy every row of the selected tables into BigQuery.</li>
<li>Start reading the binlog <strong>from the recorded position</strong>.</li>
</ol>
<p>Changes that happen during a long copy aren't lost: they're in the binlog after the recorded position and are applied afterwards. The ordering rule below makes it safe if a change touches a row that the snapshot already copied.</p>
<h2>Step 3: land changes in an append-only change log</h2>
<p>Don't try to <code>UPDATE</code> BigQuery row by row as events arrive. BigQuery is built for appends and big set-based statements, and DML quotas will stop you. Instead, append every event to a change-log table:</p>
<table>
<thead>
<tr>
<th>column</th>
<th>meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>_op</code></td>
<td><code>c</code> insert, <code>u</code> update, <code>d</code> delete</td>
</tr>
<tr>
<td><code>_seq</code>, <code>_row</code></td>
<td>binlog position of the event, and the row's index within it</td>
</tr>
<tr>
<td><code>_ingested_at</code></td>
<td>when it reached BigQuery</td>
</tr>
<tr>
<td>the table's columns</td>
<td>the row values after the change (before, for deletes)</td>
</tr>
</tbody></table>
<p>Appends via the Storage Write API land within seconds, and the change log is useful on its own: it's a full history of every row ("what was this order's status last Tuesday?").</p>
<p>Two details matter here:</p>
<ul>
<li><strong>Only save your read position after BigQuery has accepted the batch.</strong> Then a crash replays a few events instead of losing them.</li>
<li><strong>Replays must be harmless.</strong> That's what <code>_seq</code> is for.</li>
</ul>
<h2>Step 4: merge into an up-to-date table</h2>
<p>Analysts want a table that looks like MySQL, not an event log. On a schedule, apply the new changes with a <code>MERGE</code>, keeping only the latest change per primary key:</p>
<pre><code class="language-sql">MERGE `analytics.orders` t
USING (
  SELECT * EXCEPT (rn) FROM (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY id ORDER BY _seq DESC, _row DESC) AS rn
    FROM `analytics_changelog.orders`
    WHERE _seq &gt; @last_applied_seq
  ) WHERE rn = 1
) s
ON t.id = s.id
WHEN MATCHED AND s._op = 'd' AND s._seq &gt; t._seq THEN DELETE
WHEN MATCHED AND s._seq &gt; t._seq THEN
  UPDATE SET status = s.status, total = s.total, _seq = s._seq
WHEN NOT MATCHED AND s._op != 'd' THEN
  INSERT (id, status, total, _seq) VALUES (s.id, s.status, s.total, s._seq);
</code></pre>
<p>Ordering by binlog position (not by arrival time) means applying the same batch twice, or overlapping batches, can't produce a wrong row. The <code>s._seq &gt; t._seq</code> guard protects rows the snapshot copied after a change was already applied.</p>
<h2>Step 5: handle schema changes and the ugly types</h2>
<ul>
<li><strong>New columns:</strong> when MySQL gains a column, add it to both BigQuery tables before applying events that contain it.</li>
<li><strong>Type mapping:</strong> <code>BIGINT UNSIGNED</code> doesn't fit <code>INT64</code> (use <code>NUMERIC</code>), wide <code>DECIMAL</code>s need <code>BIGNUMERIC</code>, <code>TIME</code> can exceed 24 hours so store it as <code>STRING</code>, and <code>TINYINT(1)</code> is MySQL's boolean. (<a href="https://quayen.com/tools/mysql-to-bigquery-schema-converter">Free converter</a> that applies these rules to a <code>CREATE TABLE</code>.)</li>
<li><strong>Primary keys are mandatory.</strong> Without one there's no way to match an update or delete to a row.</li>
</ul>
<h2>The part that surprises people: BigQuery cost</h2>
<p>With on-demand pricing, a <code>MERGE</code> that updates or deletes rows is billed for the whole target table it scans (or the partitions it touches). Refresh a 200 GiB table every 5 minutes and that's hundreds of TiB a month. The fixes: refresh less often, partition by a column that's set once (like <code>created_at</code>), use insert-only merges when a batch has no updates or deletes, and skip tables with no new changes. I wrote up the numbers in a separate post.</p>
<h2>Build or buy</h2>
<ul>
<li><strong>Debezium + Kafka + a BigQuery sink:</strong> flexible and open source, but you operate Kafka Connect, schema history, and write the merge logic above yourself.</li>
<li><strong>Google Cloud Datastream:</strong> serverless and inside your GCP project, priced per GiB processed.</li>
<li><strong>Fivetran, Airbyte, Hevo, Estuary:</strong> broad connector platforms; check how often they actually merge into BigQuery and how they meter volume.</li>
<li><a href="https://quayen.com"><strong>Quayen</strong></a><strong>:</strong> does only MySQL to BigQuery, with the snapshot, change log, ordered merges and cost controls above built in.</li>
</ul>
<p>Whichever you choose, check these four things: deletes are removed (not just flagged), changes are ordered by log position, the read position is saved only after BigQuery accepts a batch, and you know what each refresh costs.</p>
]]></content:encoded></item></channel></rss>