<?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[OLake]]></title><description><![CDATA[Step-by-step guides for moving Postgres, MySQL, MongoDB, Kafka and S3 data into Snowflake, BigQuery, Databricks and Iceberg, with real errors and fixes.]]></description><link>https://olake-blog.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/66dd6cb576b27dcdc2f3b5a9/0ae2f23d-00cd-4e1d-ba6d-1b9708a535eb.png</url><title>OLake</title><link>https://olake-blog.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 07:52:22 GMT</lastBuildDate><atom:link href="https://olake-blog.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Postgres to BigQuery CDC on BigLake: Where the Setup Stops, and the Exact Messages]]></title><description><![CDATA[Set up Postgres to BigQuery CDC through a BigLake catalog with OLake Go and fix each error: credential mode, query location, INFORMATION_SCHEMA.]]></description><link>https://olake-blog.hashnode.dev/postgres-to-bigquery-cdc-on-biglake-where-the-setup-stops-and-the-exact-messages</link><guid isPermaLink="true">https://olake-blog.hashnode.dev/postgres-to-bigquery-cdc-on-biglake-where-the-setup-stops-and-the-exact-messages</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[bigquery]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[cdc]]></category><category><![CDATA[google cloud]]></category><dc:creator><![CDATA[Shubham Baldava]]></dc:creator><pubDate>Wed, 11 Nov 2026 20:31:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/0c67f346-d874-4367-ad22-8f189b1cb11c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Replicating Postgres into BigQuery through a BigLake catalog is a short pipeline with a surprising number of places to stop. Few of them are in Postgres. Most are one Google Cloud setting: a credential mode, a query location, or a table name with one part too many.</p>
<p>These notes follow the setup in the order it has to happen. Wherever it can stop, you get the exact message that comes back and the change that clears it.</p>
<p>The pipeline is change data capture (CDC) from the Postgres write-ahead log (WAL). <a href="https://github.com/datazip-inc/olake">OLake Go</a> (open source, Apache 2.0) reads the log and commits Apache Iceberg tables into BigLake, which is Google Cloud's Iceberg representational state transfer (REST) catalog. The files sit in a Cloud Storage bucket, and BigQuery reads the catalog directly, so the path has no Datastream, no Kafka and no load job.</p>
<p>I ran this on Postgres 16 with OLake Go v1.1.0 and BigQuery in <code>us-central1</code>. The data was 8 customers and 40 orders, followed by 12 changes.</p>
<h2>1. Postgres: settings, role, publication, slot</h2>
<p>OLake Go uses logical replication with the built-in <code>pgoutput</code> plugin, available in Postgres 10 and later. Four things have to exist before the first sync:</p>
<pre><code class="language-sql">-- 1. Logical replication. wal_level needs a restart.
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 10;
ALTER SYSTEM SET max_wal_senders = 10;
ALTER SYSTEM SET wal_sender_timeout = 0;   -- so a long first snapshot is not cut off

-- 2. A role for OLake Go
CREATE ROLE olake WITH LOGIN REPLICATION PASSWORD '&lt;password&gt;';
GRANT USAGE ON SCHEMA public TO olake;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO olake;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO olake;

-- 3. A publication and a pgoutput slot
CREATE PUBLICATION olake_pub FOR TABLE customers, orders;
SELECT pg_create_logical_replication_slot('olake_slot', 'pgoutput');
</code></pre>
<p>Confirm it took:</p>
<pre><code class="language-sql">SHOW wal_level;                                        -- logical
SELECT slot_name, plugin, active FROM pg_replication_slots;
SELECT pubname, puballtables FROM pg_publication;
</code></pre>
<p>Managed Postgres sets <code>wal_level</code> through a parameter group or a server setting, not through <code>ALTER SYSTEM</code>. The rest is identical.</p>
<p>Two rules save trouble later. Give every job its own slot and publication, because two jobs on one slot consume each other's changes and both lose data. And give any table without a primary key <code>ALTER TABLE &lt;table&gt; REPLICA IDENTITY FULL;</code>, so its updates and deletes carry the old row.</p>
<h2>2. Google Cloud: API, bucket, service account, key</h2>
<p>In the console, inside the project that will hold the tables:</p>
<ol>
<li>Enable the <strong>BigLake API</strong> under <strong>APIs and services</strong>.</li>
<li>Create a <strong>Cloud Storage</strong> bucket in the region where BigQuery will run queries. Its name becomes the catalog name in SQL.</li>
<li>Under <strong>IAM and Admin</strong> (Identity and Access Management), <strong>Service Accounts</strong>, create a service account. Grant it <strong>BigLake Editor</strong> on the project and <strong>Storage Object User</strong> on the bucket.</li>
<li>On the account's <strong>Keys</strong> tab, use <strong>Add key</strong>, <strong>Create new key</strong>, <strong>JSON</strong>. The file downloads once.</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/7348357c-aeb2-4eca-9962-6e8e791b454c.png" alt="Google Cloud console IAM Grant access panel giving the OLake service account the BigLake Editor role on the project" style="display:block;margin:0 auto" />

<p>You do not create the catalog. BigLake creates a single-bucket catalog, named after the bucket, the first time a client calls the endpoint with <code>gs://&lt;bucket&gt;</code> as the catalog path. It appears under <strong>BigLake</strong>, <strong>Catalogs</strong> after the first connection test.</p>
<p>The new catalog is in <strong>end-user</strong> credential mode, where every client brings its own storage credentials. Keep it there. The first error below is what happens otherwise.</p>
<h2>3. OLake UI: source and destination</h2>
<p>OLake UI comes from the Docker Compose install and listens on <code>http://localhost:8000</code>.</p>
<p><strong>Source.</strong> Create a Postgres source, keep <strong>Enable CDC</strong> on, and enter the <strong>Replication Slot</strong> and <strong>Publication</strong> from step 1. My Postgres ran in another container, so the host was <code>host.docker.internal</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/dee293e9-de42-49f9-99bc-bec162a791d7.png" alt="OLake UI Edit source form for Postgres showing Enable CDC on, slot olake_slot, publication olake_pub, database shop and user olake" style="display:block;margin:0 auto" />

<p><strong>Set up source</strong> runs the connection test and the CDC checks before anything is saved. A wrong <code>wal_level</code> or a missing slot is reported here, with the setting named and its recommended value.</p>
<p><strong>Destination.</strong> Pick <strong>Apache Iceberg</strong> and the <strong>BigLake</strong> catalog type. The <strong>REST Catalog URI</strong> is <code>https://biglake.googleapis.com/iceberg/v1/restcatalog</code> for every project and region, the <strong>BigLake Catalog Path</strong> is <code>gs://&lt;bucket&gt;</code> with no folder after it, and the <strong>Authentication Type</strong> is <code>GCP</code>. Paste the whole key file into <strong>GCP Service Account JSON</strong> and set the <strong>GCP Project ID</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/a1f930a8-14b6-4ae8-876d-f7ddd8304e3e.png" alt="OLake UI Edit destination form with catalog type Big Lake, the BigLake REST catalog URI, a gs:// bucket path and the GCP project ID" style="display:block;margin:0 auto" />

<p><strong>Create</strong> makes OLake Go call the endpoint, then write and drop a test table in the bucket.</p>
<h3>Stop: a <code>403</code> on the destination test</h3>
<p>There is no long message to quote for this one. The useful part is the host the <code>403</code> comes from.</p>
<p>From <code>biglake.googleapis.com</code>, the service account lacks <strong>BigLake Editor</strong>, the role that lets it create namespaces and commit tables. From <code>storage.googleapis.com</code>, it lacks <strong>Storage Object User</strong> on the bucket, which is what lets it write the Parquet and metadata files.</p>
<h3>Error: the catalog is in vended-credentials mode</h3>
<pre><code class="language-text">failed to test destination: failed to create or get table: rpc error: code = Internal desc = Failed to process request: Malformed request: X-Iceberg-Access-Delegation header must be present and contain `vended-credentials` when credential mode is `CREDENTIAL_MODE_VENDED_CREDENTIALS`.
</code></pre>
<p>The catalog was created with, or later switched to, <code>--credential-mode=vended-credentials</code>. OLake Go 1.1.0 does not send the header BigLake requires in that mode. It is tracked as issue 1369 in the OLake GitHub repository.</p>
<p>Switch the catalog back:</p>
<pre><code class="language-bash">gcloud beta biglake iceberg catalogs update &lt;bucket&gt; --credential-mode=end-user
</code></pre>
<p>If you run OLake Go from the command line instead of the UI, there is a second way out. Setting <code>"rest_access_delegation": "vended-credentials"</code> in <code>destination.json</code> makes the same test pass. The UI has no field for it yet.</p>
<h2>4. The job and the first sync</h2>
<p>On the job, under <strong>Advanced Settings</strong>, I set <strong>Target Query Engines</strong> to BigQuery, Spark and DuckDB. That list decides the Iceberg delete format: OLake Go only offers formats every listed engine reads. For these three that left equality and positional deletes, and the run used <strong>Positional Deletes</strong>.</p>
<p>Each table went in as <strong>Full Refresh + CDC</strong> with <strong>Upsert</strong> as the ingestion mode.</p>
<p>The historical load took 35 seconds for 48 records (2.23 KB), and that figure includes starting the container. The namespace is built from the job name, the database and the schema, so job <code>postgres_to_bigquery</code> on database <code>shop</code> produced <code>postgres_to_bigquery_shop_public</code>.</p>
<h2>5. The first query, and two ways it fails</h2>
<p>A BigLake catalog is a BigQuery data source in the same project, so nothing is registered. The bucket is the catalog, the namespace is the dataset, and the name has four parts:</p>
<pre><code class="language-sql">SELECT id, name, city, tier
FROM `&lt;project-id&gt;.&lt;bucket&gt;.postgres_to_bigquery_shop_public.customers`
ORDER BY id;
</code></pre>
<h3>Error: BigQuery cannot find the dataset</h3>
<pre><code class="language-text">Not found: Dataset &lt;project&gt;:&lt;bucket&gt;.postgres_to_bigquery_shop_public was not found in location US
</code></pre>
<p>Nothing is missing. The query ran in a location other than the bucket's region, and the default <code>US</code> multi-region does not see a catalog whose bucket is regional.</p>
<p>In the SQL editor, open <strong>More</strong>, then <strong>Query settings</strong>, and choose the bucket's region. With the <code>bq</code> tool, pass <code>--location=&lt;region&gt;</code>. Leaving the location unset also works, because BigQuery then picks it from the table.</p>
<h3>Error: INFORMATION_SCHEMA rejects the name</h3>
<pre><code class="language-text">Invalid project ID '&lt;project&gt;.&lt;bucket&gt;'. Project IDs must contain 6-63 lowercase letters, digits, or dashes.
</code></pre>
<p><code>INFORMATION_SCHEMA</code> views expect a dataset-qualified name and do not resolve catalog tables, so the parser reads the first two parts as a project ID. To list columns and types, use this instead:</p>
<pre><code class="language-bash">bq show --schema &lt;project&gt;:&lt;bucket&gt;.&lt;namespace&gt;.&lt;table&gt;
</code></pre>
<h2>6. The change sync, and the one that hangs</h2>
<p>Between the syncs I made 12 changes in Postgres: 7 inserts and 5 updates across the two tables. The follow-up sync took 12 records and 594 bytes off the slot in 58 seconds, and the larger share of that time went to the WAL reader confirming the slot was idle.</p>
<p>The commit report in the sync log shows what was written for <code>customers</code>: <code>addedDataFiles=1</code>, <code>addedRecords=4</code>, <code>addedPositionalDeleteFiles=1</code>, <code>addedPositionalDeletes=2</code>. That is 2 inserts and 2 new row versions in one file, and one delete file retiring the 2 old versions.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/53709a88-721f-4bda-947e-40dc2badbbb6.png" alt="OLake UI sync log filtered to the Iceberg commit report, with addedDataFiles 1 and addedPositionalDeleteFiles 1 per table" style="display:block;margin:0 auto" />

<p>BigQuery returned 10 customers on the next query, one per key, without a refresh or an <code>ALTER TABLE</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/a3b73a6c-5a58-4037-b35c-a4053e68ae43.png" alt="BigQuery SQL editor showing the customers table read through the BigLake catalog, 10 rows after the change sync" style="display:block;margin:0 auto" />

<h3>Stop: the next sync waits on the slot</h3>
<p>This one has no error text either, only a sync that hangs after the snapshot. Either another process holds the replication slot, or <code>wal_sender_timeout</code> cut the connection during a quiet period.</p>
<p>Look in <code>pg_stat_replication</code> for a second consumer of <code>olake_slot</code>. Then make sure <code>wal_sender_timeout = 0</code> is set, as in step 1.</p>
<h2>7. A second reader, and its <code>401</code></h2>
<p>DuckDB 1.5.5 attached the same catalog with a Google access token and read the files with a hash-based message authentication code (HMAC) key for the service account. It returned the same 10 customers. Spark 4.0.1 with Iceberg 1.10.0 did the same from a laptop.</p>
<p>The access token lives an hour. When a DuckDB query starts failing with <code>401</code>, run <code>CREATE OR REPLACE SECRET</code> with a fresh token.</p>
<h2>Not errors, but they will catch you</h2>
<ul>
<li><code>numeric</code> <strong>arrives as</strong> <code>FLOAT64</code><strong>.</strong> OLake Go stores decimals as doubles. The order sums in this run still matched Postgres to the cent, but round at query time for exact decimal arithmetic.</li>
<li><code>json</code> <strong>and</strong> <code>jsonb</code> <strong>arrive as</strong> <code>STRING</code><strong>.</strong> <code>PARSE_JSON</code> or <code>JSON_VALUE</code> turns them back.</li>
<li><strong>The slot pins WAL.</strong> A job paused for a weekend on a busy server can hold tens of gigabytes. Drop the slot when a job is retired.</li>
<li><code>metadata.json</code> <strong>has a ceiling.</strong> BigLake rejects one above 1 MB, and every snapshot OLake Go commits is kept in it. Expire snapshots and compact on a schedule.</li>
<li><strong>Deletion vectors are not an option yet.</strong> BigQuery reads them only in preview, so the job stays on positional deletes while BigQuery is a reader.</li>
<li><strong>This is not native BigQuery speed.</strong> Each query reads Parquet from the bucket. For a hot dashboard, load that slice into a native table on a schedule.</li>
</ul>
<p>The complete guide, with the Spark and DuckDB connection steps and the data type table, is here: <a href="https://olake.io/docs/integrations/postgres-to-bigquery/">Postgres to BigQuery on olake.io</a>.</p>
]]></content:encoded></item><item><title><![CDATA[MySQL to Snowflake: A Binlog Pipeline in Order, With the Exact Error at Each Step]]></title><description><![CDATA[Set up MySQL to Snowflake replication from the binlog with OLake Go and fix each error: binlog format, network policy, table stats, quoting.]]></description><link>https://olake-blog.hashnode.dev/mysql-to-snowflake-a-binlog-pipeline-in-order-with-the-exact-error-at-each-step</link><guid isPermaLink="true">https://olake-blog.hashnode.dev/mysql-to-snowflake-a-binlog-pipeline-in-order-with-the-exact-error-at-each-step</guid><category><![CDATA[MySQL]]></category><category><![CDATA[snowflake]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[cdc]]></category><dc:creator><![CDATA[Shubham Baldava]]></dc:creator><pubDate>Wed, 11 Nov 2026 05:40:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/bf0c49e4-113b-43de-af92-98d55daddc57.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A replication pipeline fails in a predictable order. First the source refuses, then the destination refuses, then the first sync stops, and finally the query cannot find the table. These notes walk a MySQL binlog pipeline into Snowflake in that order and pin the exact error text to the step that produces it.</p>
<p>The pipeline is change data capture (CDC) from the MySQL binary log, read by <a href="https://github.com/datazip-inc/olake">OLake Go</a> (open source, Apache 2.0) and committed as Apache Iceberg tables into Snowflake's Horizon catalog. Snowflake manages the tables itself, so there is no Kafka, no Debezium and no <code>COPY INTO</code>.</p>
<p>I ran this against MySQL 8.4 with OLake Go v1.1.0 and a Snowflake account on AWS <code>ap-south-1</code>. The data was 8 customers and 40 orders, followed by 12 changes.</p>
<p>One error below stopped that run on its first attempt, and it is marked. The others are what this same setup returns when a single setting is off.</p>
<h2>1. MySQL: the binlog, and a user that can read it</h2>
<p>OLake Go reads the same stream MySQL replicas use. These go in <code>my.cnf</code>, or in the parameter group on managed MySQL, followed by a restart:</p>
<pre><code class="language-ini">server-id = 1
log_bin = mysql-bin
binlog_format = ROW
binlog_row_metadata = FULL
binlog_expire_logs_seconds = 604800
</code></pre>
<p>Then the user:</p>
<pre><code class="language-sql">CREATE USER IF NOT EXISTS 'olake'@'%' IDENTIFIED BY '&lt;password&gt;';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'olake'@'%';
FLUSH PRIVILEGES;
</code></pre>
<p>Verify what the server actually has:</p>
<pre><code class="language-sql">SHOW VARIABLES WHERE Variable_name IN ('log_bin', 'binlog_format', 'binlog_row_metadata', 'server_id');
SHOW GRANTS FOR 'olake'@'%';
</code></pre>
<p><code>ROW</code> format matters because statement-based logging does not carry row images. <code>FULL</code> row metadata puts column names and types into every event, which is how a column added later is recognised.</p>
<h3>Error: the source test rejects the binlog</h3>
<pre><code class="language-text">binlog_format: current STATEMENT, recommended ROW
</code></pre>
<p>This appears in the OLake UI source test, which names the failing setting along with its current and recommended value. <code>binlog_format</code> and <code>binlog_row_metadata</code> can be changed at runtime with <code>SET GLOBAL</code>. Write them to <code>my.cnf</code> or the parameter group as well, or they revert on restart, and note that <code>log_bin</code> itself needs a restart.</p>
<h3>No error, but data goes missing</h3>
<ul>
<li><strong>A table without a primary key replicates inserts only.</strong> Its updates and deletes cannot be matched to a row and are skipped. Add a primary key or a unique index first.</li>
<li><strong>Aurora MySQL ships with the binlog off.</strong> Enable it on the cluster parameter group.</li>
</ul>
<h2>2. Snowflake: database, role, service user, policy, token</h2>
<p>Run once as <code>ACCOUNTADMIN</code> in a Snowsight worksheet:</p>
<pre><code class="language-sql">USE ROLE ACCOUNTADMIN;

CREATE WAREHOUSE IF NOT EXISTS OLAKE_WH WAREHOUSE_SIZE = XSMALL AUTO_SUSPEND = 60 AUTO_RESUME = TRUE;
CREATE DATABASE IF NOT EXISTS OLAKE_DEMO;
ALTER DATABASE OLAKE_DEMO SET CATALOG = 'SNOWFLAKE';
ALTER DATABASE OLAKE_DEMO SET EXTERNAL_VOLUME = 'SNOWFLAKE_MANAGED';

CREATE ROLE IF NOT EXISTS OLAKE_ROLE;
GRANT USAGE, CREATE SCHEMA ON DATABASE OLAKE_DEMO TO ROLE OLAKE_ROLE;
GRANT USAGE ON WAREHOUSE OLAKE_WH TO ROLE OLAKE_ROLE;
GRANT ROLE OLAKE_ROLE TO ROLE SYSADMIN;

CREATE USER IF NOT EXISTS OLAKE_SVC TYPE = SERVICE DEFAULT_ROLE = OLAKE_ROLE;
GRANT ROLE OLAKE_ROLE TO USER OLAKE_SVC;

USE SCHEMA OLAKE_DEMO.PUBLIC;
CREATE NETWORK RULE IF NOT EXISTS olake_ip
  MODE = INGRESS TYPE = IPV4 VALUE_LIST = ('&lt;OLake Go egress IP&gt;/32');
CREATE NETWORK POLICY IF NOT EXISTS olake_network_policy
  ALLOWED_NETWORK_RULE_LIST = ('OLAKE_DEMO.PUBLIC.olake_ip');
ALTER USER OLAKE_SVC SET NETWORK_POLICY = olake_network_policy;

SELECT CURRENT_ORGANIZATION_NAME() || '-' || CURRENT_ACCOUNT_NAME() AS account_identifier,
       CURRENT_REGION() AS region;

ALTER USER OLAKE_SVC ADD PROGRAMMATIC ACCESS TOKEN OLAKE_PAT
  ROLE_RESTRICTION = 'OLAKE_ROLE' DAYS_TO_EXPIRY = 30;
</code></pre>
<p>Setting <code>CATALOG</code> and <code>EXTERNAL_VOLUME</code> at database level is what turns each new table into a Snowflake-managed Iceberg table. Copy the token secret when the final statement prints it, because it is displayed a single time.</p>
<h3>Error: the token statement is refused</h3>
<pre><code class="language-text">Network Policy is required when creating a programmatic access token for user OLAKE_SVC
</code></pre>
<p>Snowflake issues a programmatic access token (PAT) to a service user only under a network policy. Create the rule and the policy, attach the policy to the user, then add the token. On a laptop the IP to allow is your public IP, which <code>curl -s https://checkip.amazonaws.com</code> returns.</p>
<h3>Error: the destination test fails at the catalog handshake</h3>
<p>There is no single string for this one, because Snowflake's reply depends on the cause. OLake Go exchanges the token at <code>/v1/oauth/tokens</code> before it does anything else, and three causes cover nearly every failure:</p>
<ol>
<li>The service user has no network policy, or the sync comes from an IP outside it.</li>
<li>The <code>scope</code> does not match the token's <code>ROLE_RESTRICTION</code>. It must be <code>session:role:OLAKE_ROLE</code>.</li>
<li>The token has expired. The setup above gives it 30 days, the default is 15 and the maximum is 365.</li>
</ol>
<p>Reproduce it outside the UI with the call OLake Go makes:</p>
<pre><code class="language-bash">curl -s -X POST "https://&lt;account_identifier&gt;.snowflakecomputing.com/polaris/api/catalog/v1/oauth/tokens" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode grant_type=client_credentials \
  --data-urlencode scope=session:role:OLAKE_ROLE \
  --data-urlencode client_secret=&lt;your PAT&gt;
</code></pre>
<p>A healthy token comes back as JSON containing an <code>access_token</code>. A broken one returns the same error Snowflake hands to OLake Go.</p>
<h2>3. OLake UI: source, destination, job</h2>
<p>OLake UI comes from the Docker Compose install and serves on <code>http://localhost:8000</code>.</p>
<p><strong>Source.</strong> Choose MySQL and leave <strong>Enable CDC</strong> on. In this run the host was <code>host.docker.internal</code> because MySQL sat in another container, the database was <code>shop</code>, the user was <code>olake</code>, and <strong>Initial Wait Time</strong> was 10.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/dcc047f8-3dcc-4106-b60d-6986ce739c6e.png" alt="OLake UI Edit source form for MySQL on OLake Go v1.1.0 with Enable CDC on, initial wait time 10, host host.docker.internal, database shop and user olake" /></p>
<p><strong>Destination.</strong> Choose Apache Iceberg, catalog type <strong>Horizon</strong> and authentication <strong>Programmatic Access Token (PAT)</strong>. The catalog URI is <code>https://&lt;account_identifier&gt;.snowflakecomputing.com/polaris/api/catalog</code>, the database is <code>OLAKE_DEMO</code>, the scope is <code>session:role:OLAKE_ROLE</code>, and the storage region was <code>AWS_AP_SOUTH_1</code>.</p>
<p><strong>Job.</strong> Under <strong>Advanced Settings</strong>, list every engine that will read the tables as a target query engine. I listed Snowflake, DuckDB and Apache Spark, and OLake Go resolved the upsert type to <strong>Positional Deletes</strong> on both streams, with sync mode <strong>Full Refresh + CDC</strong>.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/107a39ca-7515-4cdb-90d0-40390557bad8.png" alt="OLake UI Streams Selection page with customers and orders selected, Full Refresh + CDC, ingestion mode Upsert and upsert type Positional Deletes on Iceberg format version V2" /></p>
<h2>4. The first sync</h2>
<h3>Error: the snapshot stops before copying a row (my run hit this)</h3>
<pre><code class="language-text">failed to get or split chunks: stats not populated for table[shop.orders]. Please run ANALYZE TABLE to update table statistics
</code></pre>
<p>OLake Go plans the parallel snapshot from <code>information_schema</code> row estimates. A table that was just created or just loaded has no estimates yet.</p>
<p>The fix is one statement per table, then <strong>Sync Now</strong> again:</p>
<pre><code class="language-sql">ANALYZE TABLE customers, orders;
</code></pre>
<p>After that the first sync copied 48 records, 8 customers plus 40 orders, in 34 seconds with container start included.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/08882cab-e107-4b49-8808-2d6af7956af0.png" alt="OLake UI Job Logs and History for mysql_to_snowflake showing two completed syncs, the first in 34 seconds and the second in 23 seconds" /></p>
<h2>5. The first query</h2>
<p>The destination schema takes its name from the job and the source database. Job <code>mysql_to_snowflake</code> on database <code>shop</code> produced <code>mysql_to_snowflake_shop</code>.</p>
<h3>Error: Snowflake cannot find the table</h3>
<pre><code class="language-text">SQL compilation error:
Object 'OLAKE_DEMO.MYSQL_TO_SNOWFLAKE_SHOP.CUSTOMERS' does not exist or not authorized.
</code></pre>
<p>The table exists. Snowflake upper-cased an unquoted identifier, while the schema and tables were created with their lowercase MySQL names. Quote them:</p>
<pre><code class="language-sql">SHOW ICEBERG TABLES IN SCHEMA OLAKE_DEMO."mysql_to_snowflake_shop";

SELECT "id", "name", "city", "tier"
FROM OLAKE_DEMO."mysql_to_snowflake_shop"."customers"
ORDER BY "id";
</code></pre>
<h2>6. The change sync</h2>
<p>I made 12 changes in MySQL: 2 new customers, 5 new orders, 2 customer updates and 3 order status updates. The next sync read 12 records (563 bytes) from the binlog and took 23 seconds.</p>
<p>The commit report in the sync log gives the file-level picture for <code>customers</code>: <code>addedDataFiles=1</code>, <code>addedRecords=4</code>, <code>addedPositionalDeleteFiles=1</code>, <code>addedPositionalDeletes=2</code>. That is 2 inserts plus 2 new row versions in one data file, and a delete file that retires the 2 old versions. <code>orders</code> looked the same with 8 rows and 3 deletes.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/ff363785-e86d-419c-aa4e-8f13ede4fa28.png" alt="OLake UI sync log filtered to the Iceberg commit reports for customers and orders, showing addedDataFiles 1, addedPositionalDeleteFiles 1, addedRecords 4 and 8, totalEqualityDeletes 0" /></p>
<p>Snowsight showed the result with no refresh. The <code>_op_type</code> column reads <code>u</code> on the two updated customers, <code>c</code> on the two new ones and <code>r</code> on the untouched snapshot rows.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/441ee4d9-081d-460e-b25c-ca565c0a2289.png" alt="Snowsight query on customers after the CDC sync showing 10 rows with _op_type r for the snapshot rows, u for Liam Carter and Zara Ali, c for Maya Patel and Lucas Silva" /></p>
<h3>Error: a later sync cannot find its binlog position</h3>
<pre><code class="language-text">idle timeout reached, exiting binlog syncer
</code></pre>
<p>There are two causes. If <code>binlog_expire_logs_seconds</code> has already removed the saved binlog file, the position is gone, so run a fresh full snapshot and lengthen retention.</p>
<p>If the file is still there but the server is slow to seek to the position, raise <strong>Initial Wait Time</strong> on the source. 10 seconds was enough for this two-table database.</p>
<h2>Things that raise no error and still surprise people</h2>
<ul>
<li><strong><code>decimal</code> becomes <code>FLOAT</code>.</strong> An order roll-up still matched MySQL to the cent in Snowflake, DuckDB 1.5.5 and Spark 3.5.6, but round at query time for exact decimal arithmetic.</li>
<li><strong><code>datetime</code> and <code>timestamp</code> become <code>TIMESTAMP_LTZ(6)</code> in UTC.</strong> <code>json</code>, <code>enum</code>, <code>time</code> and <code>bit(1)</code> arrive as <code>VARCHAR</code>.</li>
<li><strong>Freshness is a schedule,</strong> down to one minute, not sub-second streaming.</li>
<li><strong>The network policy follows the token everywhere.</strong> DuckDB or Spark reading through Horizon must also connect from an allowed IP.</li>
<li><strong>DuckDB is not an option on an Azure-hosted Snowflake account.</strong> It attaches REST catalogs backed by S3, S3 Tables or Google Cloud Storage only.</li>
<li><strong>Binlog retention costs disk.</strong> Seven days on a busy server can be tens of gigabytes.</li>
</ul>
<p>The AWS Glue variant, the DuckDB and Spark connection steps and the full data type table are in the <a href="https://olake.io/docs/integrations/mysql-to-snowflake/">MySQL to Snowflake guide on olake.io</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Postgres to Snowflake CDC: Setup Notes and the Errors You Will Hit]]></title><description><![CDATA[Set up Postgres to Snowflake CDC with OLake and fix the errors you will hit: walsender, network policy, token exchange, stale rows, quoting.]]></description><link>https://olake-blog.hashnode.dev/postgres-to-snowflake-cdc-setup-notes-and-the-errors-you-will-hit</link><guid isPermaLink="true">https://olake-blog.hashnode.dev/postgres-to-snowflake-cdc-setup-notes-and-the-errors-you-will-hit</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[snowflake]]></category><category><![CDATA[data-engineering]]></category><category><![CDATA[cdc]]></category><dc:creator><![CDATA[Shubham Baldava]]></dc:creator><pubDate>Tue, 10 Nov 2026 09:39:50 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/f6f8d223-d41f-42c8-9e7b-bd696294c970.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Postgres to Snowflake guides stop at "it works". This one is the setup in the order it has to happen, plus every error the pipeline threw on the way, with the exact message and the fix.</p>
<p>The pipeline: Postgres logical replication, read by <a href="https://github.com/datazip-inc/olake">OLake</a> (open source, Apache 2.0), written as Apache Iceberg tables into Snowflake's own catalog. Snowflake sees them as Snowflake-managed tables, so there is no stage, no <code>COPY INTO</code> and no Kafka.</p>
<p>The run behind this post: Postgres 16, OLake UI with the Postgres source on v1.1.0, an X-Small Snowflake warehouse. It used 8 customers and 40 orders, then 12 changes.</p>
<h2>1. Postgres: four settings, one restart</h2>
<pre><code class="language-sql">ALTER SYSTEM SET wal_level = 'logical';      -- needs a restart
ALTER SYSTEM SET max_replication_slots = 10;
ALTER SYSTEM SET max_wal_senders = 10;
ALTER SYSTEM SET wal_sender_timeout = 0;     -- so a long first snapshot is not cut off

CREATE ROLE olake WITH LOGIN REPLICATION PASSWORD '&lt;password&gt;';
GRANT USAGE ON SCHEMA public TO olake;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO olake;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO olake;

CREATE PUBLICATION olake_pub FOR TABLE customers, orders;
SELECT pg_create_logical_replication_slot('olake_slot', 'pgoutput');
</code></pre>
<p>Check before moving on:</p>
<pre><code class="language-sql">SHOW wal_level;                                         -- logical
SELECT slot_name, plugin, active FROM pg_replication_slots;
SELECT pubname, puballtables FROM pg_publication;
</code></pre>
<p>On RDS, Aurora, Azure or Cloud SQL, <code>wal_level</code> is a parameter-group or server setting, not <code>ALTER SYSTEM</code>.</p>
<h3>Error: the replication connection is refused</h3>
<pre><code class="language-text">FATAL: must be superuser or replication role to start walsender
</code></pre>
<p>The user has <code>SELECT</code> but not the <code>REPLICATION</code> attribute. Run <code>ALTER ROLE olake WITH REPLICATION;</code>. On RDS and Aurora, <code>REPLICATION</code> is reserved, so grant the <code>rds_replication</code> role instead.</p>
<h3>Not an error yet, but it will be</h3>
<ul>
<li><strong>Tables without a primary key</strong> replicate inserts only. Give them <code>ALTER TABLE &lt;table&gt; REPLICA IDENTITY FULL;</code> so updates and deletes carry the old row.</li>
<li><strong>One slot and one publication per job.</strong> Two jobs sharing a slot read each other's changes, and both lose data.</li>
<li><strong>The slot holds WAL until it is read.</strong> Pause a job for a week and Postgres keeps a week of WAL. Set <code>max_slot_wal_keep_size</code> and alert on <code>pg_replication_slots.wal_status</code>.</li>
</ul>
<h2>2. Snowflake: database, role, service user, network policy, token</h2>
<p>Run once as <code>ACCOUNTADMIN</code>:</p>
<pre><code class="language-sql">CREATE WAREHOUSE IF NOT EXISTS OLAKE_WH WAREHOUSE_SIZE = XSMALL AUTO_SUSPEND = 60 AUTO_RESUME = TRUE;
CREATE DATABASE IF NOT EXISTS OLAKE_DEMO;
ALTER DATABASE OLAKE_DEMO SET CATALOG = 'SNOWFLAKE';
ALTER DATABASE OLAKE_DEMO SET EXTERNAL_VOLUME = 'SNOWFLAKE_MANAGED';

CREATE ROLE IF NOT EXISTS OLAKE_ROLE;
GRANT USAGE, CREATE SCHEMA ON DATABASE OLAKE_DEMO TO ROLE OLAKE_ROLE;
GRANT USAGE ON WAREHOUSE OLAKE_WH TO ROLE OLAKE_ROLE;
GRANT ROLE OLAKE_ROLE TO ROLE SYSADMIN;

CREATE USER IF NOT EXISTS OLAKE_SVC TYPE = SERVICE DEFAULT_ROLE = OLAKE_ROLE;
GRANT ROLE OLAKE_ROLE TO USER OLAKE_SVC;

USE SCHEMA OLAKE_DEMO.PUBLIC;
CREATE NETWORK RULE IF NOT EXISTS olake_ip
  MODE = INGRESS TYPE = IPV4 VALUE_LIST = ('&lt;OLake egress IP&gt;/32');
CREATE NETWORK POLICY IF NOT EXISTS olake_network_policy
  ALLOWED_NETWORK_RULE_LIST = ('OLAKE_DEMO.PUBLIC.olake_ip');
ALTER USER OLAKE_SVC SET NETWORK_POLICY = olake_network_policy;

ALTER USER OLAKE_SVC ADD PROGRAMMATIC ACCESS TOKEN OLAKE_PAT
  ROLE_RESTRICTION = 'OLAKE_ROLE' DAYS_TO_EXPIRY = 30;
</code></pre>
<p>The two database settings are what make every table Snowflake-managed, on Snowflake's own storage. You don't need an S3 bucket, an IAM role or an external volume of your own.</p>
<h3>Error: the token is refused before it exists</h3>
<pre><code class="language-text">Network Policy is required when creating a programmatic access token for user OLAKE_SVC
</code></pre>
<p>Snowflake issues a programmatic access token to a service user only under a network policy. Create the rule and the policy first, then the token. On a laptop the egress IP is your public IP (<code>curl -s https://checkip.amazonaws.com</code>). On a server, it is the NAT gateway or public IP.</p>
<h3>Error: the destination test fails at the token exchange</h3>
<p>OLake exchanges the token at <code>/v1/oauth/tokens</code> before anything else, so a bad token fails there. On a later rerun of this setup, the cause was a home IP that had changed since the rule was written, and the exchange came back <code>unauthorized_client</code>. Adding the new IP to <code>olake_ip</code> fixed it. The other two causes, in order of likelihood:</p>
<ol>
<li><strong>Wrong scope or role.</strong> The scope must be <code>session:role:OLAKE_ROLE</code>, matching the token's <code>ROLE_RESTRICTION</code>.</li>
<li><strong>Expired token.</strong> The default is 15 days. Re-issue it with <code>ALTER USER ... ADD PROGRAMMATIC ACCESS TOKEN</code>.</li>
</ol>
<p>Test outside the UI with the same call OLake makes:</p>
<pre><code class="language-bash">curl -s -X POST "https://&lt;account_identifier&gt;.snowflakecomputing.com/polaris/api/catalog/v1/oauth/tokens" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode grant_type=client_credentials \
  --data-urlencode scope=session:role:OLAKE_ROLE \
  --data-urlencode client_secret=&lt;your PAT&gt;
</code></pre>
<p>A good token returns JSON with an <code>access_token</code>.</p>
<h2>3. OLake: source, destination, job</h2>
<p>OLake UI runs from the Docker Compose install and listens on <code>http://localhost:8000</code>.</p>
<p><strong>Source.</strong> Pick Postgres, turn on <strong>Enable CDC</strong>, and enter the slot and publication from step 1. If Postgres runs in another container, the host is <code>host.docker.internal</code>.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/f02cd063-0379-41cd-8321-bacadafb17f9.png" alt="OLake source form for Postgres with Enable CDC on and the replication slot and publication filled in" /></p>
<p><strong>Set up source</strong> runs the connection test and the CDC checks before it saves. All four required checks have to pass. This is where a missing <code>wal_level</code> or a missing slot shows up, before any data moves.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/2743b9cf-54c9-4522-9921-87bc866ea326.png" alt="Source test showing the connection, 4 of 4 required CDC checks and 1 of 1 optional setting passed" /></p>
<p><strong>Destination.</strong> Pick Apache Iceberg, catalog type <strong>Horizon</strong>, and authentication <strong>Programmatic Access Token (PAT)</strong>. The catalog URL is <code>https://&lt;account_identifier&gt;.snowflakecomputing.com/polaris/api/catalog</code>, the warehouse is <code>OLAKE_DEMO</code>, and the scope is <code>session:role:OLAKE_ROLE</code>.</p>
<p><strong>Job.</strong> Set the target query engines to Snowflake, plus DuckDB or Spark if they will read the tables. OLake picks the update and delete format every listed engine can read and sets it on each stream. In this run that was positional deletes.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/68bd8737-ccfc-439d-a8f3-83f15a65dec9.png" alt="OLake job page with Snowflake, DuckDB and Spark as target query engines" /></p>
<h3>Error: Snowflake shows stale rows</h3>
<p>A job created without a target query engine defaults to equality deletes, and Snowflake does not read those. The updates land, but Snowflake keeps showing the old rows.</p>
<p>Fix: edit the job's streams and set the target query engine to Snowflake. OLake switches the upsert type, and on the next sync it rewrites the existing delete files into the new format.</p>
<h2>4. Query it, and the last error</h2>
<p>The first sync copied 8 customers and 40 orders in 41 seconds, container start included. The schema is named after the job, the database and the source schema, so <code>pg_to_snowflake</code> on database <code>shop</code> became <code>pg_to_snowflake_shop_public</code>.</p>
<h3>Error: the table "does not exist"</h3>
<pre><code class="language-text">SQL compilation error:
Object 'OLAKE_DEMO.PG_TO_SNOWFLAKE_SHOP_PUBLIC.CUSTOMERS' does not exist or not authorized.
</code></pre>
<p>Snowflake upper-cases unquoted names, and OLake keeps the lowercase Postgres names. Quote them:</p>
<pre><code class="language-sql">SELECT "id", "name", "city", "tier", "_op_type", "updated_at"
FROM OLAKE_DEMO."pg_to_snowflake_shop_public"."customers"
ORDER BY "id";
</code></pre>
<p>The rule runs both ways: if you create the database as <code>"olake_demo"</code> in quotes, OLake must be given <code>olake_demo</code>, not <code>OLAKE_DEMO</code>.</p>
<h2>5. The change sync</h2>
<p>12 changes went in: 7 inserts and 5 updates. The second sync read only those 12 records (589 bytes) from the slot and finished in 23 seconds.</p>
<p>For <code>customers</code> that meant one new data file with 4 rows, plus one small delete file that retires the 2 old versions. The sync log's commit report shows it: <code>addedDataFiles=1</code>, <code>addedRecords=4</code>, <code>addedPositionalDeleteFiles=1</code>, <code>addedPositionalDeletes=2</code>.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/c021d187-97d1-4f60-b49e-9783a7b66c6e.png" alt="OLake sync log with the Iceberg commit report for the change sync" /></p>
<p>Each row carries <code>_op_type</code>: <code>r</code> for snapshot rows, <code>c</code> for inserts and <code>u</code> for updates. A deleted row stays as a marker with <code>_op_type = 'd'</code> and null fields, so filter it out in queries that should not see deletes.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/66dd6cb576b27dcdc2f3b5a9/04c87dc5-a7d0-4d10-b633-e9eac6a87a07.png" alt="Snowsight after the change sync: updates marked u, inserts marked c, snapshot rows r" /></p>
<h2>Things that are not errors but will surprise you</h2>
<ul>
<li><strong><code>numeric</code> arrives as <code>FLOAT</code>.</strong> The order totals still matched Postgres to the cent, but round at query time when you need exact decimal arithmetic.</li>
<li><strong>Timestamps become <code>TIMESTAMP_LTZ(6)</code> in UTC.</strong> <code>json</code>, <code>jsonb</code>, <code>uuid</code> and arrays arrive as <code>VARCHAR</code>.</li>
<li><strong>Freshness is a schedule,</strong> down to one minute, not sub-second streaming.</li>
<li><strong>Small files add up.</strong> A table that gets a new file every minute wants compaction. Snowflake compacts manifests for free, and data-file compaction is up to the writer side.</li>
</ul>
<p>The full guide has the data type table, the AWS Glue variant and the DuckDB and Spark connection steps: <a href="https://olake.io/docs/integrations/postgres-to-snowflake/">Postgres to Snowflake on olake.io</a>.</p>
]]></content:encoded></item></channel></rss>