This page is for data engineers, analytics engineers and analysts facing a Snowflake round, from a first data job to a senior platform role. Most rounds open with the architecture and virtual warehouses, then move to micro-partitions and clustering, Time Travel and cloning, and how data gets in through stages, COPY INTO and Snowpipe. Stronger rounds add streams and tasks, JSON handling, roles and data sharing, and finish on performance and cost. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud. Practise saying them, then swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Storage: tables live in cloud object storage as compressed, columnar micro-partitions that Snowflake manages for you.
Compute: virtual warehouses are independent clusters that run queries; many can read the same data at once.
Cloud services: the brain: login, security, metadata, query parsing and optimisation, transactions and the result cache.
"Snowflake has three layers that scale on their own. At the bottom is storage: every table is kept in the cloud provider's object storage, broken into compressed columnar files called micro-partitions, and I never manage those files directly. In the middle is compute, which is virtual warehouses. A warehouse is just a cluster of machines that runs my queries, and I can have several, for loading, for BI, for data science, all reading the same tables without fighting each other. On top is the cloud services layer. It handles authentication and access control, keeps the metadata about every micro-partition, parses and optimises queries, manages transactions, and holds the result cache. The practical point is that storage and compute are billed and sized separately, so I can add compute for a heavy job without copying any data."
Describing Snowflake as a database running on one big server, or saying each warehouse holds its own copy of the data.
Scope: what moved, how big it was and the plan for cutting over.
Surprises: real differences you hit, such as unenforced keys, case handling, costs or tuning habits that no longer applied.
Validation and lessons: how you proved the numbers matched, and what you'd change next time.
"My last big project was moving our reporting warehouse from an on-premise database to Snowflake, a few hundred tables and the nightly jobs around them. We ran both systems side by side for a month and compared row counts and key totals for every table each night before switching any report. Two things surprised us. First, the old system enforced primary keys and Snowflake doesn't, so a job that relied on duplicate inserts failing started quietly doubling rows; we added merges and uniqueness tests. Second, people brought their old habits, like building indexes and leaving one big warehouse running all day, so the first month's bill was higher than planned. We split warehouses per workload and set auto-suspend and resource monitors. Next time I'd set up the cost controls and data tests on day one, not after the first surprise."
A migration story with no validation of the numbers, or claiming nothing behaved differently.
What it is: a named compute cluster that runs queries and DML; it holds no data of its own.
Sizes: each step up roughly doubles the compute and the credits per hour.
Choosing: start small, run the real workload, go up only if big queries speed up enough to justify the extra credits.
"A virtual warehouse is a named cluster of compute that runs my queries and loads. It doesn't store any data; it reads from the shared storage layer and caches some of it locally while it's running. Sizes go from X-Small upwards, and each step roughly doubles the compute and the credits it burns per hour. So a size up helps a single large query, like a big join or aggregation, because more machines work on it at once. If a query takes eight minutes on Small and four on Medium, the cost is about the same and I get the answer faster. If it barely speeds up, the bigger size is wasted. So I start small, run the real workload, look at the query profile for spilling, and move up only when the numbers justify it."
Saying a bigger warehouse is always better, or that a larger size fixes queuing from many concurrent users.
Scale up: a bigger size for heavy individual queries, such as big joins, sorts or spilling.
Scale out: more clusters of the same size for many users at once; extra clusters start when queries queue and stop when load drops.
Settings: minimum and maximum clusters, plus a scaling policy that favours speed or saving credits.
"I scale up when individual queries are heavy: a transformation that spills to disk or a join over billions of rows gets faster on a bigger warehouse. I scale out when the queries are fine on their own but there are too many at once, like a BI tool at nine in the morning when a hundred dashboards refresh and queries start queuing. A multi-cluster warehouse keeps the same size but adds clusters when queries queue and removes them when the load drops. I set a minimum and maximum number of clusters, and a scaling policy: Standard starts extra clusters quickly to avoid queuing, Economy waits until there's enough work to keep a new cluster busy, which saves credits but lets some queries wait. Multi-cluster needs Enterprise edition or higher, so I'd check that first."
Answering every performance complaint with a bigger size, without asking whether queries are slow or just waiting.
Billing: credits per second while the warehouse runs, with a minimum of 60 seconds each time it starts.
Auto-suspend: stops the warehouse after a set idle time, so idle time stops costing.
Trade-off: suspending clears the local data cache, so the first queries after a restart can be slower.
"Warehouses burn credits for as long as they're running, not per query. Billing is per second, but every time a warehouse starts there's a one-minute minimum. Auto-suspend shuts a warehouse down after it's been idle for the time I set, and auto-resume starts it again the moment a query arrives, so users don't notice. For an ad hoc or ELT warehouse I keep auto-suspend short, a minute or a few minutes, because paying for an idle cluster is the most common waste I see. The trade-off is the local cache. A running warehouse keeps recently read data on its local disks, and suspending throws that away, so a BI warehouse that's hit constantly might get a slightly longer idle time to keep dashboards warm. Setting it to a few seconds is also a mistake, since every restart costs a minimum minute."
Thinking you pay per query like a serverless engine, or leaving warehouses running around the clock by default.
Confirm: check query history for queued time and who was running what during the load window.
Isolate: give loads and ad hoc work separate warehouses, each sized and suspended for its own pattern.
Guardrails: statement timeouts and resource monitors on the ad hoc warehouse, and a word with the analysts.
"First I'd confirm it with evidence. I'd look at query history for the load window: how long the load queries sat queued, and which other queries were running at the same time. If it's the analysts' queries, the fix in Snowflake is simple: separate warehouses. Loads get their own warehouse, sized for the transformation and suspended when done, so their timing no longer depends on who else is busy. Analysts get their own, maybe multi-cluster if many people query at once, with a statement timeout so a runaway query stops itself, and a resource monitor so the budget is visible. Both warehouses read the same data, so nobody loses access to anything. Then I'd talk to the analysts, not just change their setup, and show them the new warehouse and how to check their own query costs. I'd keep watching load finish times for a couple of weeks."
Just making the shared warehouse bigger, or blocking analysts from querying without talking to them.
What they are: immutable, compressed, columnar files, each holding roughly 50 to 500 MB of uncompressed data, created automatically as data is written.
Metadata: for every micro-partition the service layer stores the min and max of each column and other stats.
Pruning: a filter is checked against that metadata so partitions that can't match are never read.
"When data is written, Snowflake splits it into micro-partitions, which are immutable, compressed files holding roughly 50 to 500 megabytes of uncompressed data each, stored column by column. I don't define them; they follow the order the data arrived in. For every micro-partition the cloud services layer keeps metadata, like the minimum and maximum value of each column. So if I filter on order_date for one week, the optimiser compares that range with each partition's min and max and skips every partition that can't contain matching rows. That's pruning, and it's why Snowflake doesn't need traditional indexes. Because the files are columnar, it also reads only the columns I select. In the query profile I compare partitions scanned with partitions total: if a filtered query scans nearly everything, the data isn't laid out well for that filter."
Saying you create micro-partitions yourself like partitions in other databases, or that Snowflake uses B-tree indexes to find rows.
When: very large tables where common filters prune badly because the natural load order doesn't match them.
Choosing the key: the columns most used in filters and joins, with sensible cardinality; use an expression like a date instead of a raw timestamp.
Checking: clustering depth from the clustering information function, and partitions scanned in real query profiles, weighed against background reclustering credits.
"Most tables don't need a clustering key, because data loaded over time is naturally clustered on its load date. I'd add one when a table is very large, queries keep filtering on a column that doesn't follow the load order, and the profile shows them scanning most partitions. For the key I pick the columns used most in filters, low cardinality first, and I avoid something like a raw timestamp or a unique ID; if I need time, I cluster on a date expression instead. Once the key is set, automatic clustering reorganises micro-partitions in the background, and that uses serverless credits, more so on tables with lots of updates. To judge it I check average clustering depth with the built-in clustering information function, and more importantly I compare partitions scanned on the real queries before and after. If the reclustering bill is bigger than the savings, I drop the key."
Adding clustering keys to small tables, or never mentioning that automatic clustering costs credits.
Permanent: the default; full Time Travel and seven days of Fail-safe.
Transient: persists until dropped, Time Travel of at most one day, no Fail-safe, so cheaper to keep.
Temporary: exists only for the session that created it; no Fail-safe, gone when the session ends.
"Permanent tables are the default. They get Time Travel, which can be longer on higher editions, and after that seven days of Fail-safe, so they have the most protection and the most storage overhead. Transient tables stay until someone drops them, like permanent ones, but they have at most one day of Time Travel and no Fail-safe. I use them for staging and intermediate tables that I can rebuild from source, because on tables that are truncated and reloaded every day, Fail-safe storage adds up for no benefit. Temporary tables only live for the session that created them, can't be seen by other sessions, and disappear when the session ends, so they're handy for a scratch step inside one script. I wouldn't put anything that can't be rebuilt in a transient or temporary table."
Saying transient tables vanish at the end of a session, or storing data that can't be rebuilt in one without Fail-safe.
What: query or restore data as it was at an earlier point, within the table's retention period.
How: AT or BEFORE with a timestamp, an offset in seconds, or a query ID; UNDROP for dropped objects.
Limit: retention defaults to one day and can be longer on Enterprise edition for permanent tables.
"Time Travel lets me query or restore data as it was at an earlier moment, as long as that moment is inside the table's data retention period. The default is one day, and on Enterprise edition permanent tables can keep up to 90 days. To see the table as it was yesterday, I add an AT clause with a timestamp, or an offset in seconds back from now. If I know the exact statement that damaged the data, BEFORE with that query's ID gives me the state just before it ran, which is the most precise. To restore, I usually clone the table at that point, check it, and swap it in. It also works on dropped objects: UNDROP TABLE brings back a table dropped within the retention period. Longer retention means more storage, because changed partitions are kept."
SELECT *
FROM sales.orders AT (OFFSET => -60*60*24);
CREATE TABLE sales.orders_restored
CLONE sales.orders BEFORE (STATEMENT => '<query_id>');
Thinking Time Travel is a backup that lasts forever, or not knowing it depends on the retention setting.
Order: Fail-safe starts when Time Travel retention ends and lasts seven days for permanent tables.
Access: you can't query it; only Snowflake support can recover from it, on a best-effort basis.
Cost and scope: it adds storage cost; transient and temporary tables have none.
"Time Travel is mine to use: I can query old versions, clone from them or undrop things myself during the retention period. When that period ends, data from permanent tables moves into Fail-safe for seven more days. But I can't query Fail-safe or restore from it myself. Only Snowflake support can recover data from it, it's best effort, and it's meant for disasters like a system failure, not for undoing my team's mistakes. So I don't count it in a recovery plan. For real protection I'd set Time Travel retention to match how fast we'd notice a problem on critical tables, and keep a separate copy or replication for anything we need beyond that. It also has a cost side: Fail-safe storage is billed, so for staging tables that are reloaded daily I use transient tables, which skip Fail-safe entirely."
Telling the interviewer you'd restore from Fail-safe yourself, or treating it as a free extra week of Time Travel.
Mechanism: the clone points at the same micro-partitions as the source, so it's near-instant and uses no extra storage at first.
Divergence: changes to either side write new micro-partitions, and only those cost extra storage.
Uses: dev and test copies of production, safe snapshots before risky changes, cloning at a past point with Time Travel.
"When I clone a table, schema or database, Snowflake doesn't copy any data. It creates new metadata that points at the same micro-partitions as the source, so even a huge database clones in seconds and costs nothing extra at first. Because micro-partitions are immutable, when either the clone or the original changes, the changed data goes into new micro-partitions that belong only to that side, and those are what I start paying for. I use it a lot for dev and test: every developer can get a full clone of production to test a migration against real volumes. Before a risky backfill I clone the target table so I can swap it back if it goes wrong. And combined with Time Travel I can clone a table as it was an hour ago, which is my usual way to recover from a bad update."
CREATE DATABASE analytics_dev CLONE analytics;
CREATE TABLE sales.orders_before_backfill
CLONE sales.orders AT (OFFSET => -3600);
Saying a clone copies all the data, or that changes to the clone also change the original.
Contain: pause the tasks and jobs that read or write the table so the damage doesn't spread.
Recover: find the DELETE's query ID in history, clone the table from just before it, check it, then swap.
Follow up: tell affected users, rerun what ran on bad data, and tighten who can run DML on production.
"First I'd stop things getting worse: pause any tasks or scheduled jobs that read from or write to that table, so nothing downstream rebuilds on an empty table. Then I'd find the DELETE in query history and note its query ID. An hour is well inside Time Travel retention, so I'd create a clone of the table using BEFORE with that statement ID, which gives me the exact state just before the delete. I'd check the clone's row count and a few totals against what we expect, and then swap it with the damaged table, which is quick and keeps the name. If rows were legitimately added after the delete, I'd merge those in from the damaged table. Then I'd resume the jobs, rerun anything that ran on the bad data, and let users know. Afterwards I'd look at why that role could delete in production at all."
CREATE TABLE sales.orders_fix
CLONE sales.orders BEFORE (STATEMENT => '<delete_query_id>');
ALTER TABLE sales.orders SWAP WITH sales.orders_fix;
Restoring by timestamp by guesswork, or recovering the table while jobs keep writing to it.
Stage: a named location for files to load from or unload to.
Internal: storage managed by Snowflake: a user stage, a table stage, or a named internal stage; files go in with PUT.
External: points at your own cloud bucket or container, usually through a storage integration so no keys sit in SQL.
"A stage is a location for data files, the place COPY INTO reads from when loading and writes to when unloading. Internal stages use storage that Snowflake manages. Every user has a user stage, every table has its own table stage, and I can create named internal stages that several people and tables share. Files go into an internal stage with the PUT command from a client like SnowSQL. External stages point at storage I already own, like an S3 bucket, a Google Cloud Storage bucket or an Azure container. For those I set up a storage integration, which lets Snowflake reach the bucket through the cloud provider's own role-based access, so no access keys are written into the stage definition. In most real pipelines files already land in cloud storage from other systems, so I mostly use named external stages, and LIST to check what's there."
Thinking a stage is a table, or pasting cloud access keys straight into a stage definition.
Command: COPY INTO a table from a stage, with a file format and optionally a PATTERN to pick files.
Idempotence: load metadata remembers loaded files for 64 days, so a rerun skips them unless FORCE is set.
Errors and sizing: ON_ERROR decides abort, skip file or continue; VALIDATION_MODE tests without loading; many medium-size files load in parallel best.
"COPY INTO reads files from a stage, parses them with a file format, and inserts the rows into the table using the warehouse I'm running on. I usually point it at a folder in an external stage and add a PATTERN so it only picks the files I want. If I run the same COPY again, Snowflake checks its load metadata, which remembers which files were loaded into that table for 64 days, and skips them, so reruns after a failure are safe. FORCE overrides that, which I'd only use on purpose. For bad data, ON_ERROR controls what happens: the default for bulk loads aborts the whole statement, or I can skip a bad file or continue past bad rows. Before a first big load I run it with VALIDATION_MODE to see the errors without loading anything. For speed, files of roughly 100 to 250 megabytes compressed load well in parallel, rather than one giant file."
COPY INTO raw.orders
FROM @landing_stage/orders/
FILE_FORMAT = (TYPE = CSV SKIP_HEADER = 1
FIELD_OPTIONALLY_ENCLOSED_BY = '"')
PATTERN = '.*orders_.*[.]csv[.]gz'
ON_ERROR = 'SKIP_FILE';
Not knowing that reruns skip already-loaded files, or loading one enormous file and blaming the warehouse size.
What: a pipe wraps a COPY statement and loads new files soon after they land, on serverless compute.
Triggers: auto-ingest from cloud storage event notifications, or calls to its REST API.
When: a steady trickle of files needing fresh data; big nightly batches stay with COPY on a warehouse.
"Snowpipe is continuous loading. I create a pipe that contains a COPY INTO statement, and with auto-ingest turned on, the cloud storage sends an event notification whenever a new file lands, for example through an S3 event and a queue. Snowpipe then loads that file within a short time using serverless compute that Snowflake manages, so I don't need a warehouse running. I'd use it when files arrive all day in small pieces, like event logs every few minutes, and people want fresh data without me keeping a warehouse awake just to poll. For one large nightly drop, a scheduled COPY on a warehouse is simpler and easier to control. Two things I watch: Snowpipe doesn't guarantee files load in arrival order, and lots of tiny files add overhead, so I ask producers to batch files to a sensible size."
Calling Snowpipe real-time streaming with guaranteed ordering, or not knowing it needs event notifications for auto-ingest.
What: an object that returns the rows inserted, updated or deleted in a table since its offset, with extra metadata columns.
Offset: selecting from a stream doesn't move it; using it in a DML statement that commits does.
Types: standard tracks all changes; append-only tracks inserts only and is cheaper for insert-only sources.
"A stream is change tracking on a table. It stores an offset, a point in the table's version history, and when I query it I get the rows that changed since then, with metadata columns saying whether each row was an insert or a delete and whether it was part of an update. An update shows up as a delete and an insert pair. The stream doesn't copy the data; it uses the table's versioning. The key rule is that just selecting from a stream doesn't advance it. The offset only moves when I use the stream in a DML statement, like an INSERT or MERGE, and that transaction commits. So if the merge fails and rolls back, the changes are still there next time. For a landing table that only ever gets inserts, I use an append-only stream, which is simpler and cheaper to read."
Saying a SELECT on a stream consumes the changes, or that a stream is a full copy of the changed rows.
Task: runs one SQL statement, a stored procedure call or a short scripting block on a schedule, on a warehouse or on serverless compute.
Chaining: a root task has the schedule; children declare AFTER a parent, forming a graph.
Gotchas: tasks are created suspended and must be resumed; a WHEN condition can skip runs when a stream has no data.
"A task runs SQL on a schedule, usually one statement or a call to a stored procedure, and the schedule can be every few minutes or a CRON expression with a time zone. It runs either on a warehouse I name or on serverless compute that Snowflake sizes. To build a pipeline, the root task holds the schedule and each child task says AFTER its parent, so I get a graph: load, then transform, then refresh aggregates. The first thing that catches people is that a new task is created suspended, so nothing happens until I resume it, and in a graph the children need to be resumed too. I also add a WHEN condition that checks whether the source stream has data, so the run is skipped cheaply when nothing changed. For monitoring I query task history and alert on failed runs rather than trusting that it's running."
CREATE TASK load_orders
WAREHOUSE = etl_wh
SCHEDULE = 'USING CRON 0 * * * * UTC'
AS
CALL etl.load_orders();
CREATE TASK build_daily_sales
WAREHOUSE = etl_wh
AFTER load_orders
AS
CALL etl.build_daily_sales();
ALTER TASK build_daily_sales RESUME;
ALTER TASK load_orders RESUME;
Creating tasks and assuming they run, or having no way to notice a task graph has been failing for days.
Ingest: Snowpipe loads files into an append-only raw table, keeping the file name and load time.
Change capture: an append-only stream on the raw table feeds a scheduled task only when it has data.
Apply: MERGE keeps the latest version per order key, deduping inside the batch first.
Operate: task history alerts, row-count checks, and replay from raw if logic changes.
"I'd land files in cloud storage and let Snowpipe with auto-ingest load them into a raw table, adding the file name and load time so I can trace any row back to its file. That raw table is insert-only, so I put an append-only stream on it. A task runs every few minutes with a WHEN condition that skips the run if the stream is empty. The task runs a MERGE from the stream into the clean orders table on the order ID. Because one batch can contain the same order more than once, I first keep only the latest row per order with QUALIFY and ROW_NUMBER, ordered by the source's update time. The merge is atomic, so if it fails, the stream doesn't advance and the next run picks the changes up again. I'd alert on task failures, check row counts daily, and because raw keeps everything, I can rebuild orders if the logic changes."
CREATE STREAM raw.orders_changes ON TABLE raw.orders APPEND_ONLY = TRUE;
CREATE TASK etl.merge_orders
WAREHOUSE = etl_wh
SCHEDULE = '5 MINUTE'
WHEN SYSTEM$STREAM_HAS_DATA('raw.orders_changes')
AS
MERGE INTO clean.orders t
USING (
SELECT * FROM raw.orders_changes
QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) = 1
) s
ON t.order_id = s.order_id
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET t.status = s.status, t.amount = s.amount, t.updated_at = s.updated_at
WHEN NOT MATCHED THEN
INSERT (order_id, status, amount, updated_at)
VALUES (s.order_id, s.status, s.amount, s.updated_at);
ALTER TASK etl.merge_orders RESUME;
A plain INSERT with no dedupe, so reruns and repeated records create duplicates in the clean table.
Cause: the stream's offset fell outside the source table's retention, so the changes since then can no longer be read.
Recover: recreate the stream, then reconcile the target against the source with a full compare or a bounded reload.
Prevent: monitor task state and stream staleness, and alert when a task has been suspended or failing.
"A stream reads changes using the source table's version history, so it only works while its offset is inside the table's data retention. To help, Snowflake extends retention on a table with an unconsumed stream, up to a limit set by a parameter, 14 days by default. Weeks of no consumption went past that, so the stream is stale: the changes between its offset and now can't be read any more, and it can't just be resumed. To recover, I'd recreate the stream so it starts tracking from now, then fix the gap separately. Because I can't trust which changes were missed, I'd reconcile the target against the source: for a modest table, a full MERGE from the source; for a big one, a reload of the affected date range, then check counts and totals. Then I'd add alerts on task state and on stream staleness, so this is caught in hours, not weeks."
Recreating the stream and carrying on, as if the weeks of missed changes didn't matter.
Store: load JSON into a VARIANT column, or convert text with PARSE_JSON.
Query: colon for the first level, dots or brackets for nested keys, an index for arrays.
Cast: values come back as VARIANT, so cast with double colon to get real types and clean strings.
"I load JSON into a VARIANT column, which can hold a whole nested document per row. If the JSON arrives as plain text in a string column, PARSE_JSON turns it into a VARIANT. To query it I use a colon for the first level, like raw colon customer, then dots for deeper keys, and square brackets with an index for array items. The thing that trips people up is that the result is still a VARIANT, so a string shows up with double quotes around it and comparisons can behave oddly. I always cast with double colon, like colon status cast to STRING, or to NUMBER or TIMESTAMP. Also, the key names inside the path are case-sensitive even though Snowflake column names aren't, so a mismatch in case just returns NULL instead of an error."
SELECT
raw:order_id::STRING AS order_id,
raw:customer.email::STRING AS email,
raw:items[0].sku::STRING AS first_sku,
raw:total::NUMBER(12,2) AS total
FROM raw.orders_json;
Forgetting to cast, or not knowing that path keys are case-sensitive and silently return NULL.
FLATTEN: a table function that turns each array element into its own row.
Columns: VALUE holds the element, INDEX its position, with KEY and PATH also available.
Edge case: OUTER => TRUE keeps parent rows whose array is empty or missing.
"I'd use LATERAL FLATTEN on the items array. FLATTEN is a table function: for each order row it produces one output row per element of the array, and I join it laterally so each item row can still see its parent order. In the output, the value column holds the item itself, so I read its fields with a colon and cast them, and index gives the item's position in the array, which is handy as a line number. The detail interviewers look for is empty arrays. By default an order with no items, or with the key missing, just disappears from the result, because there's nothing to flatten. If the report needs every order, I set OUTER to TRUE, and those orders come back with NULLs in the item columns. For arrays inside arrays I'd chain a second FLATTEN on the inner array."
SELECT
o.raw:order_id::STRING AS order_id,
i.index + 1 AS line_no,
i.value:sku::STRING AS sku,
i.value:qty::NUMBER AS qty,
i.value:price::NUMBER(12,2) AS price
FROM raw.orders_json o,
LATERAL FLATTEN(input => o.raw:items, OUTER => TRUE) i;
Silently losing orders with empty arrays, or reading the whole array instead of each element's value.
Result cache: in cloud services; the same query on unchanged data returns the stored result with no warehouse.
Warehouse cache: table data kept on the warehouse's local disks while it runs; lost on suspend.
Metadata: some queries, like a plain COUNT(*) on a table, are answered from metadata alone.
"There are three layers. The result cache lives in the cloud services layer: if I run the same query text again, the underlying data hasn't changed, and the query has nothing like CURRENT_TIMESTAMP that changes each run, Snowflake returns the stored result without using a warehouse at all. Results are kept for 24 hours, and that clock resets each time the result is reused, up to a limit. That's usually why a second run is instant. Then there's the warehouse's local cache: a running warehouse keeps micro-partitions it has read on local disk, so similar queries on the same data read less from remote storage, but that's lost when it suspends. And some queries, like a plain COUNT of a table, are answered from metadata. When I benchmark a change, I turn off USE_CACHED_RESULT for the session so I'm measuring real work."
Claiming a tuning change made a query faster when the second run simply came from the result cache.
Waiting or working: check queued time first; queuing is a concurrency problem, not a query problem.
Profile signals: the most expensive operators, partitions scanned against total, bytes spilled to local or remote storage, and row counts growing through a join.
Fix to match: better pruning, a fixed join key or earlier filter, or a bigger warehouse only for real spilling.
"First I'd compare a fast run and a slow run in query history, because the difference often tells me a lot. If most of the extra time is queued, the query isn't slower, the warehouse is overloaded, and that's a scaling or scheduling fix. If it's execution time, I open the query profile and go to the most expensive operators. I look at partitions scanned against partitions total: if it now scans almost everything, pruning has broken, maybe because the filter changed or the data's load order did. I check for bytes spilled to local or, worse, remote storage, which means the warehouse ran out of memory, and that's when a size up really helps. And I look at row counts through each join; if a join outputs far more rows than it takes in, a duplicate key upstream has probably caused an exploding join. Each of those has a different fix."
Jumping straight to a bigger warehouse without looking at queuing, pruning, spilling or join row counts.
Visibility: separate warehouses per team or workload, plus the account usage views for metering and query history.
Compute: right-size, short auto-suspend, statement timeouts, and resource monitors that notify or suspend.
Other spend: serverless features like clustering and Snowpipe, and storage from Time Travel and Fail-safe on high-churn tables.
"First I make spend visible. I give each workload its own warehouse, loading, transformation, BI, data science, so metering history shows who uses what, and I use the account usage views for warehouse metering and query history to find the most expensive queries. For compute, I right-size each warehouse by testing, keep auto-suspend short, and set a statement timeout so a runaway query can't run all night. Resource monitors put a credit quota on warehouses and can notify at one level and suspend at another. But they only cover warehouses, so I also watch serverless features, like automatic clustering, Snowpipe and search optimization, which bill separately. Storage is usually smaller, but tables rewritten every day with long Time Travel and Fail-safe can quietly pile it up, so staging tables become transient. Then I review the top spenders monthly with the teams that own them."
Only mentioning warehouse size, with no way to see spend and no awareness of serverless or storage costs.
Situation: which job, how slow or costly, and who was affected.
Diagnosis: what the profile or query history showed, such as pruning, spilling or a bad join.
Fix and result: what you changed, how you measured before and after, and what you'd keep watching.
"At my last company our nightly sales model took about an hour on a Large warehouse and kept creeping up. Someone had already sized it up twice. I opened the query profile and saw two things. The biggest table scan read almost every micro-partition even though we only needed the last three days, because the incremental filter wrapped the date column in a conversion that the optimiser couldn't use for pruning well. And one join produced many times more rows than it took in, because a lookup table had picked up duplicate keys. I rewrote the filter to compare the raw date column directly, added a dedupe step and a uniqueness test on the lookup table. The job dropped to a few minutes and we moved it back to a Medium warehouse. I compared credits in metering history for two weeks to be sure the savings held."
A story where the only fix was a bigger warehouse, with no before-and-after numbers.
Model: privileges are granted to roles, roles to users and to other roles, forming a hierarchy.
System roles: ACCOUNTADMIN at the top, SECURITYADMIN and USERADMIN for grants and users, SYSADMIN for objects, PUBLIC for everyone.
Design: access roles hold object privileges, functional roles like analyst are granted those, and custom roles roll up to SYSADMIN.
"In Snowflake, privileges never go straight to users. I grant privileges on objects to roles, and then grant roles to users, and roles can be granted to other roles, so they inherit. ACCOUNTADMIN sits at the top and should be held by very few people. SECURITYADMIN and USERADMIN manage grants and users, and SYSADMIN is meant to own databases and warehouses. For a new analytics team I'd make access roles, like a read-only role on the marts schema, and a functional role called analyst that's granted those access roles plus usage on their warehouse. People get the analyst role, not the underlying grants. I'd grant select on the tables already there and add future grants so new tables are readable automatically, and I'd grant my custom roles up to SYSADMIN, so admins can still manage what those roles create and nothing ends up orphaned."
CREATE ROLE marts_read;
GRANT USAGE ON DATABASE analytics TO ROLE marts_read;
GRANT USAGE ON SCHEMA analytics.marts TO ROLE marts_read;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics.marts TO ROLE marts_read;
GRANT SELECT ON FUTURE TABLES IN SCHEMA analytics.marts TO ROLE marts_read;
CREATE ROLE analyst;
GRANT ROLE marts_read TO ROLE analyst;
GRANT USAGE ON WAREHOUSE bi_wh TO ROLE analyst;
GRANT ROLE analyst TO ROLE sysadmin;
Granting privileges straight to users, or giving whole teams ACCOUNTADMIN to get things working.
Masking policy: a rule attached to a column that returns the real value or a masked one based on the role, at query time.
One table: everyone queries the same table; no copies or separate views to maintain.
Related tools: row access policies filter rows by role; tags can apply masking to many columns at once; these need Enterprise edition.
"I'd use a masking policy. It's a small rule I create once and attach to the email and phone columns. Every time a query reads those columns, Snowflake runs the policy: if the user's role is the support role, they see the real value; everyone else gets a masked value, like stars or just the email domain. The nice part is that there's one table, analysts don't need a separate view, and the rule follows the column wherever it's queried. I'd write the policy to check role membership in the session rather than one exact role name, so role inheritance works. If the requirement were rows rather than columns, like regional managers seeing only their region, I'd use a row access policy instead. With lots of sensitive columns, I'd tag them and attach the policy to the tag. These features need Enterprise edition, which I'd confirm first."
CREATE MASKING POLICY pii_mask AS (val STRING) RETURNS STRING ->
CASE WHEN IS_ROLE_IN_SESSION('SUPPORT_PII') THEN val
ELSE '***MASKED***'
END;
ALTER TABLE crm.customers MODIFY COLUMN email SET MASKING POLICY pii_mask;
ALTER TABLE crm.customers MODIFY COLUMN phone SET MASKING POLICY pii_mask;
Making a second copy of the table without the sensitive columns and trying to keep the two in sync.
Mechanism: the provider creates a share, grants objects to it and adds consumer accounts; nothing is copied.
Consumer: creates a read-only database from the share and queries it with their own warehouse, so they pay compute.
Details: share secure views to control what each consumer sees; reader accounts serve people without Snowflake; other regions or clouds need the data replicated there first.
"With Secure Data Sharing, I as the provider create a share, grant it usage on a database and schema and select on specific tables or secure views, and add the consumer's account. No data is copied or moved. The consumer creates a database from that share and sees the live data, read-only, as soon as I update it. Storage stays with me, so I pay for that, and the consumer queries with their own warehouse, so they pay the compute. If the consumer doesn't have a Snowflake account, I can create a reader account for them, but then the compute is on my bill. For security I usually share secure views rather than raw tables, because they hide the view definition and let me filter rows per consumer. If the consumer is in another region or cloud, the data has to be replicated there first, which a listing can handle for me."
Describing sharing as exporting files or copying tables to the other account.
Starting point: what was wrong, such as too many admins or grants straight to individuals.
Plan: the role model you designed and how you mapped people onto it.
Rollout: how you changed it without breaking jobs and dashboards, and how you kept it clean afterwards.
"When I joined my last team, about a dozen people used ACCOUNTADMIN day to day, and service accounts for our BI tool and ELT jobs shared one big role. I first pulled the grants and login history from the account usage views to see who actually used what. Then I proposed a simple model: access roles per schema for read and write, functional roles for analysts, engineers and each service, all rolled up to SYSADMIN. I built the new roles next to the old ones, moved one team at a time, and watched query failures for a few days before removing the old grants. Admin access went down to two named people with MFA. The part I'm proudest of is that we moved all grants into version-controlled scripts, so any change went through review and the account stopped drifting."
Removing access in one big change with no audit first, or a story where you just handed out more admin rights.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.