<?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" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Avinash Enugu on Medium]]></title>
        <description><![CDATA[Stories by Avinash Enugu on Medium]]></description>
        <link>https://medium.com/@avinash-reddy-enugu?source=rss-4d0162f8317b------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*srf4l-VeIBCqjA1i</url>
            <title>Stories by Avinash Enugu on Medium</title>
            <link>https://medium.com/@avinash-reddy-enugu?source=rss-4d0162f8317b------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 01:37:19 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@avinash-reddy-enugu/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Breaking Out of Snowflake: Calling External APIs and SFTP Servers from SQL Queries]]></title>
            <link>https://medium.com/snowflake/breaking-out-of-snowflake-calling-external-apis-and-sftp-servers-from-sql-queries-3fe5afb65baf?source=rss-4d0162f8317b------2</link>
            <guid isPermaLink="false">https://medium.com/p/3fe5afb65baf</guid>
            <category><![CDATA[python]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[api]]></category>
            <category><![CDATA[aws]]></category>
            <dc:creator><![CDATA[Avinash Enugu]]></dc:creator>
            <pubDate>Tue, 13 Oct 2026 17:01:03 GMT</pubDate>
            <atom:updated>2026-10-13T17:01:03.677Z</atom:updated>
            <content:encoded><![CDATA[<h4><em>What I learned connecting Snowflake to the outside world — over PrivateLink, OAuth, and SFTP.</em></h4><h3>What We Were Trying to Do</h3><p>My team had a need to call a third party encryption API directly from Snowflake. The requirements were simple:</p><ul><li>Call a REST API to decrypt some tokens (such as encrypted card numbers that needed to be resolved for downstream analytics)</li><li>Authenticate with OAuth 2 client credentials</li><li>Route all traffic through AWS PrivateLink securely</li></ul><p>Snowflake’s feature: <strong>External Access Integration</strong> lets us do exactly this, to call external APIs from a Python UDF.</p><p>Getting the Snowflake side working is ~30% of the effort. The remaining 70% is coordinating with the infrastructure team, getting the AWS plumbing right, and resolving a certificate chain issue.</p><h3>Architecture (Big Picture)</h3><p>The setup has two separate network paths:</p><pre>┌─────────────────────────────────────────────────────────────┐<br>│  SNOWFLAKE ENVIRONMENT                                      │<br>│                                                             │<br>│  ┌───────────────────────────────────────────────────────┐  │<br>│  │  External Access Integration                          │  │<br>│  │  (bundles network rules + secrets together)           │  │<br>│  │                                                       │  │<br>│  │  ┌──────────────────┐    ┌─────────────────────────┐  │  │<br>│  │  │ OAUTH_PUBLIC_RULE│    │  API_PRIVATE_RULE       │  │  │<br>│  │  │ TYPE: HOST_PORT  │    │  TYPE: PRIVATE_HOST_PORT│  │  │<br>│  │  └────────┬─────────┘    └────────────┬────────────┘  │  │<br>│  └───────────┼───────────────────────────┼───────────────┘  │<br>│              │                           │                  │<br>│  ┌───────────▼───────────────────────────▼───────────────┐  │<br>│  │  Python UDF / Stored Procedure                        │  │<br>│  │  1. Get OAuth token  ──►  2. Call API with token      │  │<br>│  └───────────────────────────────────────────────────────┘  │<br>└──────────────┬───────────────────────────┬──────────────────┘<br>               │                           │<br>       Public Internet              AWS PrivateLink<br>       (outbound HTTPS)            (private network)<br>               │                           │<br>               ▼                           ▼<br>  ┌────────────────────┐     ┌──────────────────────────┐<br>  │  Identity Provider │     │  VPC Endpoint Service    │<br>  │  (OAuth 2.0 token  │     │         │                │<br>  │   endpoint)        │     │    NLB / ALB             │<br>  └────────────────────┘     │         │                │<br>                             │    Your API Service      │<br>                             └──────────────────────────┘</pre><p>We have two paths because our OAuth provider wasn’t reachable over PrivateLink. The Token requests traverse over public internet (HOST_PORT rule), and the actual API calls stay private (PRIVATE_HOST_PORT rule). There are two network rules, one integration and an UDF that relies on both.</p><h3>Step 1: Check the PrivateLink Setup</h3><p>Verify what PrivateLink looks like from your Snowflake account:</p><pre>SELECT key, value <br>FROM TABLE(FLATTEN(INPUT =&gt; PARSE_JSON(SYSTEM$GET_PRIVATELINK_CONFIG())));</pre><p>Document the VPC endpoint ID, PrivateLink account URL, and the AWS principal that needs to be whitelisted on the target service.</p><h3>Step 2: Provision the PrivateLink Endpoint</h3><pre>SELECT SYSTEM$PROVISION_PRIVATELINK_ENDPOINT(<br>    &#39;com.amazonaws.vpce.eu-west-2.vpce-svc-xxxxxxxxxxxx&#39;,<br>    &#39;api.your-internal-service.com&#39;<br>);</pre><p>Two things to know:</p><ul><li>Snowflake supports cross-region connectivity through AWS VPC Endpoint Services, provided the endpoint service is available and supported in the target Snowflake region. However, AWS PaaS services such as Amazon S3 and AWS KMS must reside in the same region as the consuming resources, as cross-region access is not supported for these services.</li><li>Admin on the AWS side should <strong>accept</strong> this connection request, or the endpoint sits in “pending” forever/</li></ul><h3>Step 2b: AWS Side</h3><p>This is the section that’s important.</p><h3>What Your Infra or Platform Team Needs to Set Up</h3><p><strong>VPC Endpoint Service</strong> — Before Snowflake can connect, we need to create the service on AWS side. In Terraform it looks like:</p><pre>resource &quot;aws_vpc_endpoint_service&quot; &quot;encryption_api&quot; {<br>  acceptance_required        = true<br>  network_load_balancer_arns = [aws_lb.api_nlb.arn]<br>  <br>  allowed_principals = [<br>    &quot;arn:aws:iam::${var.snowflake_aws_account_id}:root&quot;<br>  ]<br>}</pre><p>allowed_principals value comes from the SYSTEM$GET_PRIVATELINK_CONFIG() output from previous step. Else, the connection gets rejected.</p><p><strong>Accept the connection</strong> : After Snowflake provisions its endpoint, one who has AWS console access needs to go to VPC &gt; Endpoint Services &gt; Endpoint Connections and click Accept.</p><p><strong>DNS name should match exactly</strong> : This one cost us a lot of time. The private DNS name configured on the AWS side must be in sync with what you gave Snowflake. A subtle difference between api-service.example.com and api-service-us.example.com will give connection errors.</p><p><strong>TLS certificates</strong>: The load balancer in front of your service must present a <strong>complete</strong> certificate chain. Server cert, intermediate CA, root CA — the entire thing. If the intermediate cert is not present, Python’s SSL library (which Snowflake uses under the hood) will reject the connection.</p><h3>Talk to Your SRE Team Early</h3><p>Our infrastructure team flagged an item I hadn’t considered: the backend service sometimes had capacity issues in the past under heavy load. These are the things to consider</p><ul><li>Can the backend handle expected volume?</li><li>Are there rate limits?</li><li>Can we monitor if the service is struggling?</li><li>What is the rollback plan if you overwhelm it?</li></ul><p>We built a checklist for deployment day that covered pre-deployment (a week out), deployment day steps, and post-deployment monitoring. This is essential if you’re doing it across teams.</p><h3>Step 3: Create Network Rules</h3><p>On the Snowflake side, we need two rules — one for the API over PrivateLink, one for OAuth over the public internet:</p><pre>-- API traffic goes through PrivateLink<br>CREATE NETWORK RULE API_PRIVATE_RULE<br>    MODE = EGRESS<br>    TYPE = PRIVATE_HOST_PORT<br>    VALUE_LIST = (&#39;api.your-internal-service.com:443&#39;);</pre><pre>-- OAuth token endpoint goes over public internet<br>CREATE NETWORK RULE OAUTH_PUBLIC_RULE<br>    MODE = EGRESS<br>    TYPE = HOST_PORT<br>    VALUE_LIST = (&#39;auth.your-idp.com:443&#39;);</pre><p>PRIVATE_HOST_PORT is necessary for PrivateLink traffic, HOST_PORT is for regular internet.</p><h3>Step 4: Create the External Access Integration</h3><p>This bundles the network rules together and the reference in your UDF:</p><pre>CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION MY_API_INTEGRATION<br>    ALLOWED_NETWORK_RULES = (API_PRIVATE_RULE, OAUTH_PUBLIC_RULE)<br>    ENABLED = TRUE;</pre><pre>GRANT USAGE ON INTEGRATION MY_API_INTEGRATION TO ROLE YOUR_ROLE;</pre><h3>Step 5: The Python UDF</h3><p>Here’s the basic UDF with OAuth token caching and retry logic:</p><pre>CREATE OR REPLACE FUNCTION decrypt_token(encrypted_token STRING)<br>RETURNS STRING<br>LANGUAGE PYTHON<br>RUNTIME_VERSION = 3.10<br>PACKAGES = (&#39;requests&#39;)<br>EXTERNAL_ACCESS_INTEGRATIONS = (MY_API_INTEGRATION)<br>HANDLER = &#39;decrypt&#39;<br>AS<br>$$<br>import json<br>import requests<br>import uuid<br>import time<br>import random</pre><pre>API_URL = &quot;https://api.your-internal-service.com/v1/decrypt&quot;<br>TOKEN_URL = &quot;https://auth.your-idp.com/oauth/token&quot;<br>CLIENT_ID = &quot;your-client-id&quot;<br>CLIENT_SECRET = &quot;your-client-secret&quot;</pre><pre># Token cache - survives across rows within the same warehouse session<br>_token_cache = {<br>    &quot;access_token&quot;: None,<br>    &quot;expires_at&quot;: 0<br>}</pre><pre>def _get_oauth_token(max_retries=3):<br>    current_time = time.time()<br>    <br>    if _token_cache[&quot;access_token&quot;] and _token_cache[&quot;expires_at&quot;] &gt; (current_time + 60):<br>        return _token_cache[&quot;access_token&quot;]<br>    <br>    last_exception = None<br>    for attempt in range(max_retries):<br>        try:<br>            if attempt &gt; 0:<br>                time.sleep((2 ** attempt) + random.uniform(0, 1))<br>            <br>            resp = requests.post(<br>                TOKEN_URL,<br>                data={<br>                    &quot;grant_type&quot;: &quot;client_credentials&quot;,<br>                    &quot;client_id&quot;: CLIENT_ID,<br>                    &quot;client_secret&quot;: CLIENT_SECRET<br>                },<br>                headers={&quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot;},<br>                timeout=15<br>            )<br>            <br>            if not resp.ok:<br>                if 500 &lt;= resp.status_code &lt; 600 and attempt &lt; max_retries - 1:<br>                    continue<br>                raise Exception(f&quot;OAuth failed: HTTP {resp.status_code}&quot;)<br>            <br>            token_data = resp.json()<br>            access_token = token_data.get(&quot;access_token&quot;)<br>            expires_in = token_data.get(&quot;expires_in&quot;, 3600)<br>            <br>            if not access_token:<br>                raise Exception(&quot;No access_token in response&quot;)<br>            <br>            _token_cache[&quot;access_token&quot;] = access_token<br>            _token_cache[&quot;expires_at&quot;] = current_time + int(expires_in)<br>            return access_token<br>            <br>        except requests.exceptions.RequestException as e:<br>            last_exception = e<br>            if attempt == max_retries - 1:<br>                raise Exception(f&quot;OAuth failed after {max_retries} attempts: {e}&quot;)<br>    <br>    raise Exception(f&quot;OAuth failed after {max_retries} attempts: {last_exception}&quot;)</pre><pre>def decrypt(encrypted_token: str) -&gt; str:<br>    if encrypted_token is None or not str(encrypted_token).strip():<br>        return None<br>    <br>    trace_id = str(uuid.uuid4())<br>    <br>    try:<br>        access_token = _get_oauth_token()<br>        <br>        response = requests.post(<br>            API_URL,<br>            headers={<br>                &quot;Content-Type&quot;: &quot;application/json&quot;,<br>                &quot;Authorization&quot;: f&quot;Bearer {access_token}&quot;,<br>                &quot;X-Trace-Id&quot;: trace_id<br>            },<br>            data=json.dumps({&quot;token&quot;: str(encrypted_token)}),<br>            timeout=15<br>        )<br>        <br>        if not response.ok:<br>            raise Exception(f&quot;API error: HTTP {response.status_code}&quot;)<br>        <br>        result = response.json().get(&quot;decrypted_value&quot;)<br>        if result is None:<br>            raise Exception(f&quot;Missing &#39;decrypted_value&#39; in response&quot;)<br>        <br>        return str(result)<br>    <br>    except requests.exceptions.SSLError as e:<br>        raise Exception(f&quot;TLS/SSL error: {e}&quot;)<br>    except requests.exceptions.Timeout:<br>        raise Exception(f&quot;API timeout (trace_id={trace_id})&quot;)<br>    except requests.exceptions.RequestException as e:<br>        raise Exception(f&quot;Network error: {e}&quot;)<br>$$;</pre><p>A few things worth noting:</p><ul><li>The _token_cache dictionary persists across rows in the same query. So you&#39;re not re-authenticating for every single row - just once (or whenever the token expires).</li><li>The retry logic backs off exponentially on server errors but fails immediately on client errors (4xx). There is no point in retrying a bad request.</li><li>The trace ID on every request helps to match Snowflake errors to the API’s logs.</li><li>I’m using hardcoded credentials here for simplicity. In production, Snowflake Secrets must be used.</li></ul><h3>Making It Fast: Vectorized Batch Processing</h3><p>The single-row UDF worked fine for testing, but calling the API once per row doesn’t scale. For example 200,000 tokens, that’s 200,000 HTTP requests. That’s slow.</p><p>We rebuilt it as a batch UDF that takes an array of tokens and processes them concurrently:</p><pre>CREATE OR REPLACE FUNCTION decrypt_token_batch(<br>    token_array ARRAY,<br>    access_token VARCHAR<br>)<br>RETURNS ARRAY<br>LANGUAGE PYTHON<br>RUNTIME_VERSION = &#39;3.10&#39;<br>PACKAGES = (&#39;requests&#39;)<br>HANDLER = &#39;decrypt_batch&#39;<br>EXTERNAL_ACCESS_INTEGRATIONS = (MY_API_INTEGRATION)<br>AS $$<br>import json<br>import requests<br>import uuid<br>from concurrent.futures import ThreadPoolExecutor, as_completed</pre><pre>API_URL = &quot;https://api.your-internal-service.com/v1/decrypt&quot;</pre><pre># Reuse TCP connections across calls<br>session = requests.Session()<br>adapter = requests.adapters.HTTPAdapter(<br>    pool_connections=20,<br>    pool_maxsize=20,<br>    max_retries=2<br>)<br>session.mount(&#39;https://&#39;, adapter)</pre><pre>def decrypt_single(token: str, access_token: str) -&gt; str:<br>    if not token or not str(token).strip():<br>        return &quot;ERROR: Empty token&quot;<br>    <br>    trace_id = str(uuid.uuid4())<br>    <br>    try:<br>        resp = session.post(<br>            API_URL,<br>            headers={<br>                &quot;Content-Type&quot;: &quot;application/json&quot;,<br>                &quot;Authorization&quot;: f&quot;Bearer {access_token}&quot;,<br>                &quot;X-Trace-Id&quot;: trace_id<br>            },<br>            json={&quot;token&quot;: str(token)},<br>            timeout=10<br>        )<br>        <br>        if not resp.ok:<br>            return f&quot;ERROR: HTTP {resp.status_code}&quot;<br>        <br>        result = resp.json().get(&quot;decrypted_value&quot;)<br>        if result is None:<br>            return f&quot;ERROR: No result in response&quot;<br>        <br>        return str(result)<br>    <br>    except requests.exceptions.Timeout:<br>        return f&quot;ERROR: Timeout&quot;<br>    except Exception as e:<br>        return f&quot;ERROR: {str(e)[:200]}&quot;</pre><pre>def decrypt_batch(token_array, access_token: str):<br>    if not token_array:<br>        return []<br>    <br>    results = [None] * len(token_array)<br>    <br>    with ThreadPoolExecutor(max_workers=20) as executor:<br>        future_to_index = {<br>            executor.submit(decrypt_single, t, access_token): i<br>            for i, t in enumerate(token_array)<br>        }<br>        <br>        for future in as_completed(future_to_index):<br>            idx = future_to_index[future]<br>            try:<br>                results[idx] = future.result()<br>            except Exception as e:<br>                results[idx] = f&quot;ERROR: {str(e)[:200]}&quot;<br>    <br>    return results<br>$$;</pre><p>The two key tricks here: <strong>connection pooling</strong> (via requests.Session with an HTTPAdapter) so you&#39;re not making a new TCP connection for every call, and <strong>threading</strong> (via ThreadPoolExecutor) so 20 API calls run at the same time instead of one after another.</p><p>This took us from 45 minutes for 100K tokens down to a few minutes!</p><h3>The Real Win: A Cache Table</h3><p>Even with concurrent processing, we were still making redundant calls. The same encrypted tokens appear over and over in the data. So we added a cache table:</p><pre>CREATE TABLE IF NOT EXISTS TOKEN_DECRYPT_CACHE (<br>    encrypted_token VARCHAR(256) PRIMARY KEY,<br>    decrypted_value VARCHAR(256),<br>    first_seen TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),<br>    last_used TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),<br>    use_count NUMBER DEFAULT 1<br>)<br>CLUSTER BY (encrypted_token)<br>DATA_RETENTION_TIME_IN_DAYS = 15;</pre><p>The processing logic checks the cache table first, and only calls the API for tokens we haven’t seen before, then updates the cache with new results.</p><pre>-- 1. Check what&#39;s already cached<br>CREATE TEMPORARY TABLE temp_with_cache AS<br>SELECT<br>    src.encrypted_token,<br>    cache.decrypted_value as cached_result,<br>    CASE WHEN cache.encrypted_token IS NOT NULL THEN TRUE ELSE FALSE END as is_cached<br>FROM source_data src<br>LEFT JOIN TOKEN_DECRYPT_CACHE cache <br>    ON src.encrypted_token = cache.encrypted_token;</pre><pre>-- 2. Only make an API call for distinct uncached tokens<br>-- (batch them into arrays of 500 for vectorized UDF)</pre><pre>-- 3. MERGE new results back into the cache</pre><p>In production, our cache hit rate sits at 90–95%. That means for a single batch of 100,000 tokens, we’re only making 5,000–10,000 API calls instead of all 100,000. The processing time dropped from 45 minutes to 3–5 minutes.</p><h3>Persisting the OAuth Token</h3><p>Our in-memory token cache in the UDF resets every time the warehouse restarts. For scheduled jobs, that means a fresh OAuth request on every run. We added a table to persist tokens for all sessions:</p><pre>CREATE TABLE IF NOT EXISTS OAUTH_TOKEN_CACHE (<br>    token_type VARCHAR(50) PRIMARY KEY,<br>    access_token VARCHAR(5000),<br>    expires_at TIMESTAMP_NTZ,<br>    last_updated TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()<br>);</pre><p>And a stored procedure refreshes the token and loads it to this table. The batch UDF selects from the table. This removes unnecessary OAuth round-trips.</p><h3>Logging and Monitoring</h3><p>We track every batch run in a log table:</p><pre>CREATE TABLE IF NOT EXISTS BATCH_PROCESSING_LOG (<br>    batch_id NUMBER AUTOINCREMENT PRIMARY KEY,<br>    batch_start_time TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),<br>    batch_end_time TIMESTAMP_NTZ,<br>    records_processed NUMBER,<br>    records_successful NUMBER,<br>    records_failed NUMBER,<br>    cache_hits NUMBER DEFAULT 0,<br>    cache_misses NUMBER DEFAULT 0,<br>    api_calls_made NUMBER DEFAULT 0,<br>    cache_hit_rate_pct FLOAT<br>)<br>CLUSTER BY (batch_start_time);</pre><p>A typical log entry looks like:</p><pre>Batch 1247 completed. Processed: 85,432 | Cache Hits: 78,921 (92.4%) | <br>API Calls: 6,511</pre><p>When any issues occurs, we have the trail log to figure out what happened.</p><h3>Automating with Tasks</h3><p>We automated the entire thing with a Snowflake Task that runs after our upstream data load:</p><pre>CREATE OR REPLACE TASK PROCESS_DECRYPT_BATCH_TASK<br>  WAREHOUSE = MY_WAREHOUSE<br>  USER_TASK_TIMEOUT_MS = 7200000  -- 2 hour timeout<br>  AFTER UPSTREAM_DATA_LOAD_TASK<br>AS<br>  EXECUTE IMMEDIATE $$<br>  BEGIN<br>    CALL PROCESS_DECRYPTION_BATCH(0.25);<br>  EXCEPTION<br>    WHEN OTHER THEN<br>      CALL SYSTEM$SEND_EMAIL(<br>        &#39;MY_EMAIL_INTEGRATION&#39;,<br>        &#39;team@example.com&#39;,<br>        &#39;Decryption Batch Failed&#39;,<br>        &#39;Failed at &#39; || CURRENT_TIMESTAMP()::VARCHAR || <br>        &#39; Error: &#39; || SQLERRM<br>      );<br>      RAISE;<br>  END;<br>  $$;</pre><pre>ALTER TASK PROCESS_DECRYPT_BATCH_TASK RESUME;</pre><p>If the batch fails, team gets an email.</p><h3>The SSL Certificate Problem (a.k.a. This Part nearly Broke Us)</h3><p>This was our challenge before our go-live:</p><pre>[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: <br>Unable to get local issuer certificate</pre><p>Code worked in our test environment, but not in Production.</p><p>After working with Snowflake support, we ran:</p><pre>openssl s_client -connect api.your-internal-service.com:443 -showcerts</pre><p>We realized that our production load balancer was only serving the server certificate — no intermediate CA. Python’s SSL library (which Snowflake also uses internally) couldn’t build a trust chain back to a root CA, so it rejected the connection.</p><p>The fix was on the AWS Platform side. The infrastructure team made an update to the load balancer to present full certificate chain: server cert + intermediate CA + root CA. Once that was done, it worked.</p><p>If you hit SSL errors with PrivateLink, first check the certificate chain on the endpoint. Snowflake uses Python SSL verification. If the endpoint presents a complete chain, it works.</p><h3>Bonus: The same pattern works for SFTP</h3><p>After the REST API integration was working, we realized that we could pull files from an external SFTP server the same way. External Access Integration handles this as well — and it’s actually simpler than the API case because there’s no OAuth involved.</p><p>Our use-case was ingesting daily CSV files from an external third-party SFTP server into Snowflake. Traditionally, this was a multi-step process: syncing the SFTP files to cloud storage (Amazon S3 or Azure Blob) using intermediate orchestrators like Control-M, AWS Lambda, or cron jobs on EC2, and then loading them into the data warehouse. By using Snowflake External Access Integration, we eliminated this middle tier. Snowflake can natively and securely connect to the SFTP server.</p><h3>SFTP Architecture</h3><pre>┌─────────────────────────────────────────────────────────┐<br>│  SNOWFLAKE ENVIRONMENT                                  │<br>│                                                         │<br>│  ┌───────────────────────────────────────────────────┐  │<br>│  │  External Access Integration                      │  │<br>│  │                                                   │  │<br>│  │  ┌──────────────────┐  ┌───────────────────────┐  │  │<br>│  │  │ SFTP_NETWORK_RULE│  │  SFTP_CREDENTIAL      │  │  │<br>│  │  │ TYPE: HOST_PORT  │  │  TYPE: PASSWORD       │  │  │<br>│  │  │ host:22          │  │  (username + password)│  │  │<br>│  │  └────────┬─────────┘  └───────────┬───────────┘  │  │<br>│  └───────────┼────────────────────────┼──────────────┘  │<br>│              │                        │                 │<br>│  ┌───────────▼────────────────────────▼──────────────┐  │<br>│  │  Python Stored Procedure                          │  │<br>│  │  Connect → List files → Download → Parse → Load   │  │<br>│  └───────────────────────────────────────────────────┘  │<br>│              │                                          │<br>│              ▼                                          │<br>│  ┌──────────────────────┐                               │<br>│  │  Target Table        │                               │<br>│  └──────────────────────┘                               │<br>└──────────────┬──────────────────────────────────────────┘<br>               │<br>        Public Internet<br>          (port 22)<br>               │<br>               ▼<br>     ┌──────────────────┐<br>     │  Vendor&#39;s SFTP   │<br>     │  Server          │<br>     └──────────────────┘</pre><h3>SetUp</h3><p>The building blocks are the same as mentioned in API example — network rule, secret, integration — just configured for SFTP instead of HTTPS.</p><p><strong>Network rule</strong> — allow outbound traffic to the SFTP server on port 22:</p><pre>CREATE NETWORK RULE SFTP_ACCESS_RULE<br>    TYPE = HOST_PORT<br>    MODE = EGRESS<br>    VALUE_LIST = (&#39;sftp.vendor-partner.com:22&#39;);</pre><p><strong>Secret</strong> — store the SFTP credentials securely (no hardcoding passwords in your code):</p><pre>CREATE SECRET SFTP_CREDENTIALS<br>    TYPE = PASSWORD<br>    USERNAME = &#39;sftp_user&#39;<br>    PASSWORD = &#39;********&#39;;</pre><p><strong>External Access Integration</strong> — bind them together:</p><pre>CREATE EXTERNAL ACCESS INTEGRATION SFTP_INTEGRATION<br>    ALLOWED_NETWORK_RULES = (SFTP_ACCESS_RULE)<br>    ALLOWED_AUTHENTICATION_SECRETS = (SFTP_CREDENTIALS)<br>    ENABLED = TRUE;</pre><p><strong>Stored procedure</strong> — connect to SFTP, pull the files, parse them, and load into table:</p><pre>CREATE OR REPLACE PROCEDURE INGEST_FROM_SFTP()<br>RETURNS STRING<br>LANGUAGE PYTHON<br>RUNTIME_VERSION = 3.10<br>PACKAGES = (&#39;snowflake-snowpark-python&#39;, &#39;paramiko&#39;)<br>EXTERNAL_ACCESS_INTEGRATIONS = (SFTP_INTEGRATION)<br>SECRETS = (&#39;cred&#39; = SFTP_CREDENTIALS)<br>HANDLER = &#39;run&#39;<br>AS<br>$$<br>import paramiko<br>import csv<br>import io<br>import _snowflake</pre><pre>def run(session):<br>    # Get credentials from Snowflake secret<br>    username = _snowflake.get_username_password(&#39;cred&#39;).username<br>    password = _snowflake.get_username_password(&#39;cred&#39;).password<br>    <br>    # Connect to SFTP<br>    transport = paramiko.Transport((&#39;sftp.vendor-partner.com&#39;, 22))<br>    transport.connect(username=username, password=password)<br>    sftp = paramiko.SFTPClient.from_transport(transport)<br>    <br>    try:<br>        # List files in the drop directory<br>        files = sftp.listdir(&#39;/outbound/daily/&#39;)<br>        csv_files = [f for f in files if f.endswith(&#39;.csv&#39;)]<br>        <br>        total_rows = 0<br>        for filename in csv_files:<br>            # Download file into memory<br>            with sftp.open(f&#39;/outbound/daily/{filename}&#39;, &#39;r&#39;) as remote_file:<br>                content = remote_file.read().decode(&#39;utf-8&#39;)<br>            <br>            # Parse CSV<br>            reader = csv.DictReader(io.StringIO(content))<br>            rows = []<br>            for row in reader:<br>                rows.append(row)<br>            <br>            if rows:<br>                # Load into Snowflake table<br>                df = session.create_dataframe(rows)<br>                df.write.mode(&quot;append&quot;).save_as_table(&quot;VENDOR_DAILY_DATA&quot;)<br>                total_rows += len(rows)<br>            <br>            # Move processed file to archive<br>            sftp.rename(<br>                f&#39;/outbound/daily/{filename}&#39;,<br>                f&#39;/outbound/archive/{filename}&#39;<br>            )<br>        <br>        return f&quot;Load is complete. {total_rows} rows from {len(csv_files)} files&quot;<br>    <br>    finally:<br>        sftp.close()<br>        transport.close()<br>$$;</pre><h3>What’s unique from the API Example</h3><p>The SFTP version is simpler in a few ways:</p><ul><li><strong>No OAuth</strong> — credentials are handled by a Snowflake Secret</li><li><strong>No PrivateLink</strong> — Usually SFTP servers are reachable over the internet, so a regular HOST_PORT rule is enough</li><li><strong>No caching needed</strong> — you’re not making the same call repeatedly</li><li><strong>Uses </strong>paramiko - Python&#39;s SFTP library, available in Snowflake&#39;s package repo</li></ul><p>But the core pattern stays same: network rule controls where you can connect, secret stores credentials safely, integration bundles them, and a Python procedure does the actual work. Once you’ve set up one External Access Integration, the setup stays same with different end points.</p><h3>When to Use This vs. the Traditional Approach</h3><p>Pulling SFTP files directly into Snowflake makes sense when:</p><ul><li>Less hops (no Lambda, no EC2 cron job, no intermediate S3 bucket)</li><li>The file volumes are not huge (daily drops of CSVs in MBs or GBs but not TBs)</li><li>You want everything governed in single place — network rules, credentials, and processing logic all local to Snowflake</li></ul><p>If you’re dealing with extra large files or need streaming ingestion, the traditional SFTP -&gt; cloud-storage -&gt; Snowflake path is probably still better. But for the typical “vendor drops a file once a day” scenario, this simplifies the infrastructure.</p><h3>What I’d Do Differently</h3><p><strong>Start the cross-team conversation earlier.</strong></p><p>There’s always a need for coordination with the infrastructure team</p><p>We underestimated how much coordination was needed with the infrastructure team. DNS names, Terraform changes, TLS configuration, capacity reviews — give them at least two weeks lead time.</p><p><strong>Keep a shared doc with all the environment-specific details.</strong> URLs, DNS names, AWS account IDs, endpoint IDs — all of it. We had bits scattered across email threads and Teams messages. That was a mistake.</p><p><strong>Don’t assume production will work just because the test environment does.</strong> DNS names, certificate chains, and backend capacity can all differ between environments. Test the full chain in a non-prod or pre-prod environment that mirrors production as closely as possible.</p><p><strong>Cache early, cache often.</strong> The token cache was an afterthought for us, but it ended up being the biggest performance win. If your data has any amount of repetition, a cache table will pay for itself.</p><p><strong>Build in logging from the start.</strong> Batch processing logs with cache hit rates and error counts made troubleshooting much easier. Without them, we’d have been flying blind.</p><h3>The Bottom Line</h3><p>External Access Integration is one of those Snowflake features that’s more capable than most people realize. Whether you are calling a REST API over PrivateLink with OAuth, or pulling files off a SFTP server, the pattern is the same: network rule, secret (optional), integration, Python code.</p><p>The REST API and PrivateLink setup has the AWS-side coordination. But once it’s setup and running, it’s low-maintenance. Our batch job processes hundreds of thousands of tokens on a daily basis, the cache keeps API costs low, and it runs on a schedule.</p><p>The SFTP version is much simpler to set up and a good place to start if you want to try External Access Integration without the complexity of PrivateLink.</p><p>Either way, the payoff is the same: your data stays in Snowflake; the external calls are governed and auditable, and you don’t need to maintain extra infrastructure to move data around.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3fe5afb65baf" width="1" height="1" alt=""><hr><p><a href="https://medium.com/snowflake/breaking-out-of-snowflake-calling-external-apis-and-sftp-servers-from-sql-queries-3fe5afb65baf">Breaking Out of Snowflake: Calling External APIs and SFTP Servers from SQL Queries</a> was originally published in <a href="https://medium.com/snowflake">Snowflake Builders Blog: Data Engineers, App Developers, AI, &amp; Data Science</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How We Got 25x Faster Queries on Snowflake (And What Actually Worked)]]></title>
            <link>https://medium.com/snowflake/how-we-got-25x-faster-queries-on-snowflake-and-what-actually-worked-5adbe21737d6?source=rss-4d0162f8317b------2</link>
            <guid isPermaLink="false">https://medium.com/p/5adbe21737d6</guid>
            <category><![CDATA[performance-optimization]]></category>
            <category><![CDATA[snowflake]]></category>
            <category><![CDATA[data-engineering]]></category>
            <category><![CDATA[fintech]]></category>
            <category><![CDATA[sharding]]></category>
            <dc:creator><![CDATA[Avinash Enugu]]></dc:creator>
            <pubDate>Wed, 22 Jul 2026 21:01:01 GMT</pubDate>
            <atom:updated>2026-07-22T21:01:01.630Z</atom:updated>
            <content:encoded><![CDATA[<h4><strong>Split-table architecture, Search Optimization, and workload-aware design patterns for sub-second analytics</strong></h4><p>We migrated a high-volume merchant analytics platform to Snowflake. We went from 500ms point-lookup latency down to 20ms. A 25x improvement. We didn’t get there by turning on fancy features. 98% of what worked was just standard Snowflake tables, engineered properly — time-based sharding, clustering on the right keys, and Search Optimization on columns that actually needed it.</p><p>I’m sharing this to provide a complete perspective. When we started, there wasn’t a single resource that connected the full picture. Most articles cover clustering or Search Optimization in isolation, but our scenario was a production system handling 2 TB of organic data growth per month.</p><h4><strong>What you’ll learn:</strong></h4><ul><li>How to design hot/warm/cold data tiers that match real query patterns</li><li>When clustering alone isn’t enough and Search Optimization becomes essential</li><li>Why we chose split tables over a single consolidated surface</li><li>Connection pooling optimizations that eliminated 90% of reconnect overhead</li><li>The exact setup pattern any technical team can follow</li></ul><h3><strong>The Problem We Were Solving</strong></h3><p>This wasn’t a typical lift-and-shift. We were replacing a legacy accelerator powering a merchant-facing analytics portal. Sub-second response times, millions of daily queries, zero tolerance for regression. If the dashboard got slower after migration, we’d hear about it immediately from merchants, from product, from everyone.</p><p>The platform covers multiple transaction domains: payment authorizations, credit cards, debit cards, gift cards. Data comes in from mainframe-originated source files, transforms through several layers, and ends up serving a real-time dashboard.</p><p>Two numbers shaped every decision we made:</p><ul><li><strong>~2 TB of new transaction data per month: </strong>the tables never stop growing</li><li><strong>95%+ of queries hit the most recent 90 days:</strong> extreme recency bias</li></ul><p>That second number changed everything for us. We couldn’t just throw the entire dataset behind a single table and hope Snowflake’s pruning would handle it. At 30 TB per transaction type, even good pruning takes seconds just at the compilation stage. We needed to physically separate hot data from cold data.</p><p><strong><em>Let’s get into the implementation.</em></strong></p><h3><strong>Our Three-Layer Architecture: RAW → CURATED → APPLICATION</strong></h3><p>After a few weeks of back-and-forth with the team, we landed on three layers. Nothing groundbreaking in concept but, the devil is in how you implement each one.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pGgfxFDfbVbOQhLoFdVNng.png" /></figure><h4><strong>RAW: Just Land the Data, Don’t Touch It</strong></h4><p>RAW is append-only. We load source files from S3, preserve everything as-is. No transformations, no business logic. This gives us replayability — when something breaks downstream, we can always go back to source.</p><pre>CREATE OR REPLACE TABLE RAW.TXN_AUTH (<br>  RAW_LINE              VARCHAR,    -- entire fixed-width record stored as-is<br>  METADATA_FILENAME     VARCHAR,<br>  METADATA_FILE_ROW_NUMBER NUMBER,<br>  LOAD_TS               TIMESTAMP_NTZ<br>);<br><br> -- Load data from S3 into RAW<br>COPY INTO RAW.TXN_AUTH (RAW_LINE, METADATA_FILENAME, METADATA_FILE_ROW_NUMBER, LOAD_TS)<br>  FROM (<br>    SELECT<br>      $1,<br>      METADATA$FILENAME,<br>      METADATA$FILE_ROW_NUMBER,<br>      CURRENT_TIMESTAMP()<br>    FROM @RAW.S3_TXN_STAGE/txn_auth/<br>  )<br>  FILE_FORMAT = (<br>    TYPE            = &#39;CSV&#39;<br>    FIELD_DELIMITER = NONE      -- no delimiter; treat entire line as one field<br>    RECORD_DELIMITER = &#39;\n&#39;<br>    TRIM_SPACE      = FALSE     -- preserve exact byte positions<br>  )<br>  ON_ERROR = &#39;ABORT_STATEMENT&#39;;<br><br>CREATE OR REPLACE STREAM RAW.STR_TXN_AUTH <br>  ON TABLE RAW.TXN_AUTH <br>  APPEND_ONLY = TRUE;</pre><p>We store the entire mainframe record as a single `VARCHAR` line . Mainframe positional files are brittle; if the upstream team shifts a column, we don’t want RAW to break. All positional logic lives in the CUR stored procedure. We fail the entire load on any error (`ABORT_STATEMENT`). For error handling, we use SYSTEM$SEND_EMAIL inside the stored procedure&#39;s EXCEPTION block (shown later) to notify the support team so they can coordinate a fix with the upstream mainframe team before re-triggering.</p><h4><strong>CURATED: Where the Real Work Happens</strong></h4><p>CUR is where we flatten the raw data into proper columns, apply business logic, and *this is key* split it into time-windowed tables. The physical split happens here, not at the serving layer.</p><p>Each table gets its own clustering and Search Optimization. We cluster by merchant prefix + date (because that’s how queries come in), and apply Search Optimization on `CARD_NUMBER_HASH` a high-cardinality column that’s always used in equality lookups but would be wasteful to cluster on.</p><pre>CREATE OR REPLACE TABLE CUR.TXN_01_90D (<br>TXN_ID VARCHAR,<br>MERCHANT_ID VARCHAR,<br>CARD_NUMBER_HASH VARCHAR,<br>TXN_DATE DATE,<br>TXN_AMOUNT NUMBER(38,2),<br>LOAD_TS TIMESTAMP_NTZ<br>)<br>CLUSTER BY (SUBSTRING(MERCHANT_ID, 1, 6), TXN_DATE);<br>ALTER TABLE CUR.TXN_01_90D ADD SEARCH OPTIMIZATION<br>ON EQUALITY(CARD_NUMBER_HASH);</pre><p>One thing we got wrong initially — we tried applying Search Optimization on the same columns we were clustering by. For columns already used in range-based clustering predicates, SOS typically adds storage cost without meaningful latency benefit. For equality-only lookups on a clustering key column, SOS can still provide value — measure before deciding. In our case, the clustering key columns didn’t need it.</p><p>Before we get to the tasks, you’ll need a Notification Integration for the stored procedure email alerts. This is an account-level object, you’ll need ACCOUNTADMIN or equivalent privileges to create it:</p><pre>CREATE OR REPLACE NOTIFICATION INTEGRATION email_integration<br>TYPE = EMAIL<br>ENABLED = TRUE<br>ALLOWED_RECIPIENTS = (&#39;your-team-dl@company.com&#39;);</pre><p>The stream-to-CUR pipeline runs every 5 minutes via a Task. We use SYSTEM$SEND_EMAIL in the stored procedure&#39;s EXCEPTION block to send error notifications. This is the right pattern for email-based alerting at the SQL layer. (Note: Task-level ERROR_INTEGRATION requires a cloud messaging integration like SNS, Azure Event Grid, or Google Pub/Sub)</p><pre>CREATE OR REPLACE TASK TASK_ADMIN.TASK_TXN_AUTH_TO_CUR<br>WAREHOUSE = ETL_WH<br>SCHEDULE = &#39;5 MINUTE&#39;<br>WHEN SYSTEM$STREAM_HAS_DATA(&#39;RAW.STR_TXN_AUTH&#39;)<br>AS<br>CALL SP_PROCESS_TXN_AUTH();</pre><p>Here’s a simplified version of what `SP_PROCESS_TXN_AUTH` does. The real one is longer(handles more edge cases), but this shows the core pattern extract fields by byte position, derive business columns, and insert into the hot tier:</p><pre>CREATE OR REPLACE PROCEDURE SP_PROCESS_TXN_AUTH()<br>RETURNS STRING<br>LANGUAGE SQL<br>AS<br>$$<br>BEGIN<br>-- Field positions are defined by the mainframe file layout specification.<br>-- All positional logic is centralised here — RAW stores records verbatim.<br>-- If the upstream layout changes, only this procedure needs updating.<br>INSERT INTO CUR.TXN_01_90D (<br>TXN_ID, MERCHANT_ID, CARD_NUMBER_HASH, TXN_DATE, TXN_AMOUNT, LOAD_TS<br>)<br>SELECT<br>TRIM(SUBSTRING(RAW_LINE,  1, 20)),                      -- pos  1-20:  TXN_ID<br>TRIM(SUBSTRING(RAW_LINE, 21, 15)),                      -- pos 21-35:  MERCHANT_ID<br>SHA2(TRIM(SUBSTRING(RAW_LINE, 36, 16)))::VARCHAR,       -- pos 36-51:  CARD_NUMBER (hashed; raw PAN never lands in CUR)<br>TO_DATE(TRIM(SUBSTRING(RAW_LINE, 52, 8)), &#39;YYYYMMDD&#39;), -- pos 52-59:  TXN_DATE<br>TO_NUMBER(TRIM(SUBSTRING(RAW_LINE, 60, 12)), 10, 2),   -- pos 60-71:  TXN_AMOUNT <br>CURRENT_TIMESTAMP()<br>FROM RAW.STR_TXN_AUTH<br>WHERE METADATA$ACTION = &#39;INSERT&#39;;<br>RETURN &#39;Processed &#39; || SQLROWCOUNT || &#39; rows&#39;;<br><br>EXCEPTION<br>  WHEN OTHER THEN<br>    CALL SYSTEM$SEND_EMAIL(<br>      &#39;email_integration&#39;,<br>      &#39;your-team-dl@company.com&#39;,<br>      &#39;ERROR: SP_PROCESS_TXN_AUTH Failed&#39;,<br>      &#39;SQLCODE: &#39; || SQLCODE || &#39;\nSQLERRM: &#39; || SQLERRM || &#39;\nSQLSTATE: &#39; || SQLSTATE<br>    );<br>    RETURN &#39;FAILURE&#39;;<br>END;<br>$$;</pre><p>The positional offsets above are illustrative, replace them with your actual mainframe layout spec. We hash the card number at ingestion (`SHA2`) so the raw PAN never lands in CUR. Note that SHA2 is a one-way cryptographic hash(it’s irreversible), which is different from tokenization (which uses a reversible token vault). For our purposes, we only need a stable high-cardinality key for Search Optimization, not the ability to recover the original value.</p><h4><strong>APPLICATION: Views Only and Interactive tables</strong></h4><p>APP contains zero standard tables (with the exception of Interactive tables) Just views that UNION ALL across the CUR split tables. The benefit of this setup is that when a query comes in with a date filter, Snowflake’s optimizer prunes aggressively within each table. A query for “last 30 days” will scan CUR.TXN_01_90D fully, while the other three tables will have virtually all their partitions pruned (resulting in minimal or zero actual data scan from those older tiers).</p><pre>CREATE OR REPLACE VIEW APP.VW_TXN_AUTH AS<br>SELECT * FROM CUR.TXN_01_90D<br>UNION ALL<br>SELECT * FROM CUR.TXN_91_180D<br>UNION ALL<br>SELECT * FROM CUR.TXN_181_360D<br>UNION ALL<br>SELECT * FROM CUR.TXN_361_480D;</pre><p>From the application’s perspective, it’s just one view. The complexity is completely hidden.</p><h3><strong>The Weekly Rollover (And Why Atomicity Matters)</strong></h3><p>Every Saturday at 2 AM, a task kicks off to move data between tiers. Records older than 90 days move from `TXN_01_90D` to `TXN_91_180D`, and so on down the chain.</p><p>We learned the hard way that this needs to be atomic. During our first few test runs, we had a failure halfway through. Data was inserted into the warm tier, but not yet deleted from hot. Suddenly we had duplicate records showing up in the dashboard.</p><p>The fix was wrapping everything in a transaction with proper exception handling. If anything fails, we roll back the entire batch and fire off an email so someone can investigate before Monday morning.</p><pre>CREATE OR REPLACE PROCEDURE SP_ROLLOVER_TXN_TIERS()<br>RETURNS STRING<br>LANGUAGE SQL<br>AS<br>$$<br>BEGIN<br>BEGIN TRANSACTION;<br>DELETE FROM CUR.TXN_361_480D WHERE TXN_DATE &lt; DATEADD(day, -480, CURRENT_DATE());<br>INSERT INTO CUR.TXN_361_480D<br>SELECT * FROM CUR.TXN_181_360D WHERE TXN_DATE &lt; DATEADD(day, -360, CURRENT_DATE());<br>DELETE FROM CUR.TXN_181_360D WHERE TXN_DATE &lt; DATEADD(day, -360, CURRENT_DATE());<br>INSERT INTO CUR.TXN_181_360D<br>SELECT * FROM CUR.TXN_91_180D WHERE TXN_DATE &lt; DATEADD(day, -180, CURRENT_DATE());<br>DELETE FROM CUR.TXN_91_180D WHERE TXN_DATE &lt; DATEADD(day, -180, CURRENT_DATE());<br>INSERT INTO CUR.TXN_91_180D<br>SELECT * FROM CUR.TXN_01_90D WHERE TXN_DATE &lt; DATEADD(day, -90, CURRENT_DATE());<br>DELETE FROM CUR.TXN_01_90D WHERE TXN_DATE &lt; DATEADD(day, -90, CURRENT_DATE());<br>COMMIT;<br>RETURN &#39;SUCCESS&#39;;<br>EXCEPTION<br>WHEN OTHER THEN<br>ROLLBACK;<br>CALL SYSTEM$SEND_EMAIL(<br>&#39;email_integration&#39;,<br>&#39;youremail@example.com&#39;,<br>&#39;ERROR: SP_ROLLOVER_TXN_TIERS Failed&#39;,<br>&#39;SQLCODE: &#39; || SQLCODE || &#39;\nSQLERRM: &#39; || SQLERRM || &#39;\nSQLSTATE: &#39; || SQLSTATE<br>);<br>RETURN &#39;FAILURE&#39;;<br>END;<br>$$;<br><br>CREATE OR REPLACE TASK TASK_ADMIN.TXN_ROLLOVER_ALL_TIERS<br>WAREHOUSE = ETL_WH<br>SCHEDULE = &#39;USING CRON 0 2 * * SAT America/New_York&#39;<br>AS<br>CALL SP_ROLLOVER_TXN_TIERS();</pre><h3><strong>Why Split Tables Instead of Just Relying on Pruning?</strong></h3><p>The question came up: “Snowflake already has micro-partition pruning, why bother with manual sharding?”</p><p>Here’s what we observed: at 30 TB per transaction type, even with perfect clustering, the query compiler itself takes 2-3 seconds just to identify which partitions to scan. That’s before any actual data is read. By splitting into smaller tables (each roughly 4 TB instead of 30 TB), we cut that compilation overhead dramatically. Then clustering does its thing <em>*within*</em> the smaller table.</p><p><strong><em>The combination of physical sharding + clustering + Search Optimization is what got us to 20ms</em></strong>. No single technique alone would have done it.</p><h3><strong>Clustering + Search Optimization: How We Chose What Goes Where</strong></h3><p>We spent two weeks analyzing six months of query logs from our ACCOUNT_USAGE views. Not glamorous work, but it told us exactly what predicates our users were actually filtering on.</p><p>The pattern was clear:</p><ul><li>Almost every query filters by merchant (first 6 chars of `MERCHANT_ID`)</li><li>Date range is always present</li><li>`CARD_TYPE` shows up in about 60% of queries</li></ul><p>So that became our clustering key. For Search Optimization, we picked `CARD_NUMBER_HASH` a fully unique identifier that users search by exact match. Clustering can’t help with high-cardinality equality lookups like that. SOS can.</p><p>Results after applying both:</p><ul><li>Point-lookup latency: <strong>500ms → 20-50ms</strong></li><li>Storage overhead from SOS: ~20% (we accepted this tradeoff)</li></ul><p><em>One lesson: don’t apply SOS everywhere</em>. We initially enabled it on every column that appeared in a WHERE clause. Storage costs shot up 40% with marginal latency improvement. Be surgical about it only high-cardinality equality predicates where you’re doing needle-in-haystack lookups.</p><h3><strong>Interactive Tables: Useful, But Not for Everything</strong></h3><p>My team was excited about Interactive Tables when Snowflake announced them. We tried using them for the main transaction path. It didn’t work for us. The 5-second query timeout was too restrictive for our edge cases, and the DML limitations made our rollover process impossible.</p><p>But they were perfect for one specific use case: merchant hierarchy/demographics. This is pre-joined reference data that our dashboard queries on nearly every request. It changes multiple times a day as merchants update their profiles.</p><p>Here’s how we set it up:</p><pre>- Interactive Warehouse (required - IT tables can only be queried by these)<br>CREATE OR REPLACE INTERACTIVE WAREHOUSE APP_INTERACTIVE_WH<br>WAREHOUSE_SIZE = &#39;XSMALL&#39;<br>COMMENT = &#39;Interactive warehouse for merchant hierarchy&#39;;<br><br><br>- The Interactive Table itself - auto-refreshes from source every 5 minutes<br>CREATE OR REPLACE INTERACTIVE TABLE APP.TMD_MERCHANT_HIERARCHY<br>CLUSTER BY (REGION, MERCHANT_ID)<br>TARGET_LAG = &#39;5 minutes&#39;<br>WAREHOUSE = MY_REFRESH_WH -- Standard WH handles the refresh compute<br>AS<br>SELECT<br>MERCHANT_ID,<br>PARENT_MERCHANT_ID,<br>MERCHANT_NAME,<br>REGION<br>FROM SOURCE_MERCHANT_TABLE;<br><br>- Bind and resume<br>ALTER WAREHOUSE APP_INTERACTIVE_WH ADD TABLES (APP.TMD_MERCHANT_HIERARCHY);<br>ALTER WAREHOUSE APP_INTERACTIVE_WH RESUME;</pre><p>Important detail: the `WAREHOUSE` parameter in the Interactive Table DDL is for the <em>*refresh*</em> process. It must be a standard warehouse, not the interactive one.</p><p>We also tried static Interactive Tables first, but with data changing multiple times daily, we’d need to rebuild them via ETL each time. That gets expensive fast. The auto-refresh with `TARGET_LAG` was the right call.</p><h3><strong>APP Connection Pooling: The Bug That Wasn’t a Bug</strong></h3><p>This was a technical blocker before our final load testing. We had queries completing in 20ms on the Snowflake side (confirmed via query history), but the application was reporting 200-400ms end-to-end. Where was the extra latency?</p><p>Turns out it was connection establishment. Our Spring Boot app uses HikariCP, and the default settings are designed for steady-state traffic. Our traffic is bursty, merchants check their dashboards in waves, with 5-10 minute gaps of silence between bursts.</p><p>During those gaps, HikariCP was closing idle connections (default `idleTimeout` is 10 minutes). So when the next burst came in, every request had to re-establish a JDBC connection to Snowflake. That’s 100-300ms of pure overhead per request.</p><p>The fix was straightforward once we understood the problem:</p><pre>HikariConfig hikariConfig = new HikariConfig();<br>hikariConfig.setJdbcUrl(&quot;jdbc:snowflake://&lt;account&gt;.snowflakecomputing.com/…&quot;);<br>hikariConfig.setUsername(&quot;&lt;user&gt;&quot;);<br>hikariConfig.setPassword(&quot;&lt;password&gt;&quot;);<br>int maxPoolSize = 10;<br>hikariConfig.setMaximumPoolSize(maxPoolSize);<br>// Keep the pool full at all times - no connection spin-up during bursts<br>hikariConfig.setMinimumIdle(maxPoolSize);<br>// Don&#39;t let connections die between bursts<br>hikariConfig.setMaxLifetime(86400000); // 24 hours<br>// Still validate periodically to catch dead connections<br>hikariConfig.setIdleTimeout(600000); // 10 minutes<br>hikariConfig.setConnectionTimeout(30000);<br>DataSource dataSource = new HikariDataSource(hikariConfig);</pre><p>Setting `minimumIdle = maximumPoolSize` means the pool never shrinks. Connections stay warm. End-to-end latency dropped from 300ms+ to under 50ms consistently.</p><h3><strong>Problems That Cost Us Sleep</strong></h3><p><strong>Queries still slow despite good pruning: </strong>We had cases where scan time was 10ms but total query time was 2+ seconds. Compilation and queueing were the bottleneck. We worked with our Snowflake account team to get dedicated Global Services cluster allocation. This reduced noisy-neighbor effects significantly.</p><p><strong>TRUNCATE + INSERT killed our Interactive Tables:</strong> Our upstream team was doing TRUNCATE + INSERT to refresh hierarchy data. This completely invalidated the Interactive Table’s incremental refresh. We switched them to MERGE (SCD Type 1 style) and everything started working properly.</p><p><strong>New source columns broke everything: </strong>Someone added a column to the mainframe extract. Our stored procedures assumed positional column ordering. Lesson: always use explicit column names in INSERT and SELECT statements. Never trust positional order.</p><p><strong>Mainframe data quality: </strong>Embedded newlines in VARCHAR fields, inconsistent quoting, intermittent PGP decryption failures. We fail the load entirely (`ON_ERROR = ‘ABORT_STATEMENT’`), alert the support team via email notification, and they coordinate with the upstream mainframe team to fix and resend the file.</p><h3><strong>Snowflake Partnership was crucial</strong></h3><ol><li>Snowflake provisioned a dedicated Global Services (GS) Cluster for the warehouses that serve our users (which are used to query the APP layer) — eliminating cross customer interference</li><li>Snowflake scaled the GS Cluster capacity to align with our workload profile</li><li>Snowflake reduced the cluster spin up time to ~ 5 seconds to accommodate the query bursts</li><li>Optimized masking policies to lower query compilation time and improve overall efficiency</li></ol><h3><strong>Learnings</strong></h3><p>After living with this system in production for months, here’s what I’d say if you’re building something similar:</p><ol><li>Don’t reach for specialized features first. Standard tables with proper clustering and Search Optimization handled 98% of our performance needs. Start simple, measure, then optimize.</li><li>Look at your query logs before you design anything. We spent few weeks on ACCOUNT_USAGE analysis. It felt slow at the time. It saved us months of rework.</li><li>Physical sharding still matters in the cloud. At 30 TB, splitting tables by time window gave us a 7-8x reduction in search surface before Snowflake even starts pruning. That’s not theoretical, we measured it.</li><li>Interactive Tables are great for reference data, not for fact tables. The constraints (5s timeout, limited DML, required binding) make them unsuitable for high-volume transaction serving. But for pre-joined hierarchies that refresh throughout the day? Perfect.</li><li>Check your connection layer. We almost missed this completely. Table-level optimizations don’t matter if your app is spending 300ms re-establishing connections between queries.</li></ol><h3><strong>Final Thoughts</strong></h3><p>This was the final state after a lot of iterations. It’s just disciplined engineering. Understand your workload, measure where time is actually spent, and optimize the hot path aggressively while leaving the rest alone.</p><p>The 25x improvement came from stacking multiple optimizations: physical sharding reduces the search surface, clustering accelerates pruning within that reduced surface, Search Optimization handles the needle-in-haystack lookups, views abstract the complexity away from consumers, and connection pooling eliminates application-layer overhead.</p><p>If you’re facing a similar migration: especially from a legacy accelerator to Snowflake, don’t try to replicate what the old system did. Instead, look at how your users actually query the data and build a serving architecture around that reality.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5adbe21737d6" width="1" height="1" alt=""><hr><p><a href="https://medium.com/snowflake/how-we-got-25x-faster-queries-on-snowflake-and-what-actually-worked-5adbe21737d6">How We Got 25x Faster Queries on Snowflake (And What Actually Worked)</a> was originally published in <a href="https://medium.com/snowflake">Snowflake Builders Blog: Data Engineers, App Developers, AI, &amp; Data Science</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>