Streams and Tasks
Capture changes with a stream, consume them exactly once, and schedule the work with tasks — including task DAGs and the suspend that stops everything silently.
Streams and tasks are Snowflake’s built-in pipeline primitives: a stream tells you what changed, a task runs SQL on a schedule, and together they replace a surprising amount of external orchestration.
A stream over a table
create or replace table orders_raw (
order_id number,
customer_id number,
status string,
amount number(10,2),
updated_at timestamp_ntz
);
create or replace stream orders_stream on table orders_raw;
+---------------------------------------------+
| status |
|---------------------------------------------|
| Stream ORDERS_STREAM successfully created. |
+---------------------------------------------+
1 Row(s) produced. Time Elapsed: 0.288s
Instant, and it stores nothing — a stream is an offset into the table’s change history plus three metadata columns.
insert into orders_raw values
(1001, 1, 'pending', 25.50, current_timestamp()),
(1002, 2, 'completed', 12.00, current_timestamp());
select order_id, status, metadata$action, metadata$isupdate, metadata$row_id
from orders_stream;
+----------+-----------+-----------------+-------------------+------------------------------------------+
| ORDER_ID | STATUS | METADATA$ACTION | METADATA$ISUPDATE | METADATA$ROW_ID |
|----------+-----------+-----------------+-------------------+------------------------------------------|
| 1001 | pending | INSERT | False | a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 |
| 1002 | completed | INSERT | False | b2c3d4e5f60718293a4b5c6d7e8f901234567890 |
+----------+-----------+-----------------+-------------------+------------------------------------------+
2 Row(s) produced. Time Elapsed: 0.402s
Now update one row:
update orders_raw set status = 'completed', updated_at = current_timestamp()
where order_id = 1001;
select order_id, status, metadata$action, metadata$isupdate from orders_stream;
+----------+-----------+-----------------+-------------------+
| ORDER_ID | STATUS | METADATA$ACTION | METADATA$ISUPDATE |
|----------+-----------+-----------------+-------------------|
| 1002 | completed | INSERT | False |
| 1001 | completed | INSERT | True |
+----------+-----------+-----------------+-------------------+
2 Row(s) produced. Time Elapsed: 0.402s
Order 1001 now appears once as an INSERT with ISUPDATE = True. The stream shows the net
change since its offset — it was inserted and then updated, so the net effect is one row at
its final value. An update to a row that existed before the offset shows as a DELETE and
an INSERT pair, both with ISUPDATE = True.
Consuming advances the offset
create or replace table orders_clean like orders_raw;
merge into orders_clean t
using orders_stream s on t.order_id = s.order_id
when matched and s.metadata$action = 'DELETE' and s.metadata$isupdate = 'FALSE'
then delete
when matched and s.metadata$action = 'INSERT'
then update set t.status = s.status, t.amount = s.amount, t.updated_at = s.updated_at
when not matched and s.metadata$action = 'INSERT'
then insert values (s.order_id, s.customer_id, s.status, s.amount, s.updated_at);
+-------------------------+-------------------------+
| number of rows inserted | number of rows updated |
|-------------------------+-------------------------|
| 2 | 0 |
+-------------------------+-------------------------+
1 Row(s) produced. Time Elapsed: 0.911s
select count(*) from orders_stream;
+----------+
| COUNT(*) |
|----------|
| 0 |
+----------+
1 Row(s) produced. Time Elapsed: 0.402s
Empty. The MERGE committed, so the offset advanced past those changes — exactly once,
with no bookkeeping table of your own.
The rule that follows: a stream advances only when consumed by a committed DML statement.
A SELECT never advances it, which makes inspection safe. And because a failed transaction
does not commit, a failed pipeline step leaves the changes waiting for the retry.
Two consumers need two streams. A single stream read by two jobs means whichever runs first consumes the changes and the second sees nothing.
Append-only streams
create or replace stream events_stream on table events_raw append_only = true;
For an event table that is never updated, this is cheaper — it tracks only new rows and does
not need the delete-side bookkeeping. There is also insert_only for external tables, and
streams work on views and directory tables too.
Staleness
A stream is backed by Time Travel, so it goes stale if left unconsumed longer than the table’s retention:
show streams like 'ORDERS_STREAM';
+---------------+---------------+---------+--------------+-------------------------------+
| name | table_name | type | stale | stale_after |
|---------------+---------------+---------+--------------+-------------------------------|
| ORDERS_STREAM | ORDERS_RAW | DELTA | false | 2026-09-10 15:02:11.000 -0700 |
+---------------+---------------+---------+--------------+-------------------------------+
1 Row(s) produced. Time Elapsed: 0.194s
Past stale_after, the stream cannot be read and must be recreated — losing the changes it
had not yet reported. Extending the source table’s data_retention_time_in_days extends the
window, and a task that runs more often than the retention avoids it entirely.
Tasks
create or replace task merge_orders
warehouse = bookshop_wh
schedule = '5 minute'
when system$stream_has_data('orders_stream')
as
merge into orders_clean t
using orders_stream s on t.order_id = s.order_id
when matched and s.metadata$action = 'INSERT'
then update set t.status = s.status, t.amount = s.amount
when not matched and s.metadata$action = 'INSERT'
then insert values (s.order_id, s.customer_id, s.status, s.amount, s.updated_at);
+---------------------------------------+
| status |
|---------------------------------------|
| Task MERGE_ORDERS successfully created. |
+---------------------------------------+
1 Row(s) produced. Time Elapsed: 0.288s
alter task merge_orders resume;
Tasks are created suspended. Forgetting the RESUME is the most common reason a
pipeline “does nothing” with no error anywhere — there is no failure to see, because nothing
ran.
The WHEN system$stream_has_data(...) clause is what makes a five-minute schedule cheap: the
condition is evaluated in the services layer for free, and the warehouse is only started when
there is work. Without it, an idle pipeline resumes a warehouse 288 times a day to do
nothing.
Schedules take either an interval or cron:
schedule = '5 minute'
schedule = 'using cron 0 6 * * * Europe/London' -- 06:00 London, DST-aware
Omitting warehouse makes it serverless — Snowflake sizes the compute itself and bills
per second of actual work, which is usually cheaper for short, frequent tasks:
create or replace task merge_orders
schedule = '5 minute'
user_task_managed_initial_warehouse_size = 'xsmall'
as ...
Task DAGs
A task with an AFTER clause becomes a child, running only when its predecessors succeed:
create or replace task load_staging
warehouse = bookshop_wh
schedule = 'using cron 0 5 * * * UTC'
as
insert into stg_orders select * from orders_clean where updated_at > dateadd('day', -1, current_timestamp());
create or replace task build_marts
warehouse = bookshop_wh
after load_staging
as
insert overwrite into daily_revenue
select ordered_at, count(*), sum(amount) from stg_orders group by 1;
create or replace task refresh_exports
warehouse = bookshop_wh
after build_marts
as
copy into @bookshop_stage/export/revenue_ from (select * from daily_revenue) overwrite = true;
-- children first, root last
alter task refresh_exports resume;
alter task build_marts resume;
alter task load_staging resume;
select system$task_dependents_enable('load_staging');
+---------------------------------------------------+
| SYSTEM$TASK_DEPENDENTS_ENABLE('LOAD_STAGING') |
|---------------------------------------------------|
| Task LOAD_STAGING and its dependents are resumed. |
+---------------------------------------------------+
1 Row(s) produced. Time Elapsed: 0.402s
The ordering rule matters: a root task cannot be resumed while it has suspended children in
some configurations, and resuming the root first can let it fire before the children are
live. system$task_dependents_enable does the whole tree correctly in one call.
Only the root carries a schedule; children inherit their timing from their parents.
Watching them run
select
name,
state,
scheduled_time,
completed_time,
error_code,
error_message
from table(information_schema.task_history(
scheduled_time_range_start => dateadd('hour', -6, current_timestamp())))
order by scheduled_time desc
limit 5;
+------------------+-----------+-------------------------------+-------------------------------+------------+---------------------------------+
| NAME | STATE | SCHEDULED_TIME | COMPLETED_TIME | ERROR_CODE | ERROR_MESSAGE |
|------------------+-----------+-------------------------------+-------------------------------+------------+---------------------------------|
| REFRESH_EXPORTS | SUCCEEDED | 2026-09-09 15:12:04.000 -0700 | 2026-09-09 15:12:09.114 -0700 | NULL | NULL |
| BUILD_MARTS | SUCCEEDED | 2026-09-09 15:11:58.000 -0700 | 2026-09-09 15:12:03.882 -0700 | NULL | NULL |
| LOAD_STAGING | SUCCEEDED | 2026-09-09 15:11:50.000 -0700 | 2026-09-09 15:11:57.204 -0700 | NULL | NULL |
| MERGE_ORDERS | SKIPPED | 2026-09-09 15:10:00.000 -0700 | NULL | NULL | NULL |
| MERGE_ORDERS | SUCCEEDED | 2026-09-09 15:05:00.000 -0700 | 2026-09-09 15:05:03.402 -0700 | NULL | NULL |
+------------------+-----------+-------------------------------+-------------------------------+------------+---------------------------------+
5 Row(s) produced. Time Elapsed: 0.688s
SKIPPED is the WHEN clause working — no data in the stream, no warehouse started, no
credits spent.
A failure looks like this:
| BUILD_MARTS | FAILED | 2026-09-09 16:11:58.000 -0700 | ... | 100038 | Numeric value 'n/a' is not recognized |
| REFRESH_EXPORTS | SKIPPED | NULL | NULL | NULL | NULL |
The child was skipped rather than running on incomplete data. Note that nothing emails you —
alerting means querying task_history yourself, or attaching a notification integration.
Tasks that overrun their schedule do not overlap: the next run is skipped while one is
still going. A task suspended by
suspend_task_after_num_failures stops silently after repeated errors, which is another
reason to monitor state rather than assume.
Practice
1. Create a stream, insert rows, and select from it twice.
2 Row(s) produced.
2 Row(s) produced.
Identical both times — SELECT does not advance the offset. Then run a MERGE that consumes
it and select again: zero rows. That asymmetry between reads and DML is the whole contract.
2. Update a pre-existing row and inspect the metadata columns.
+----------+-----------------+-------------------+
| ORDER_ID | METADATA$ACTION | METADATA$ISUPDATE |
|----------+-----------------+-------------------|
| 1001 | DELETE | True |
| 1001 | INSERT | True |
+----------+-----------------+-------------------+
Two rows for one update. A MERGE that treats every DELETE as a deletion will drop the
row — which is why the merge above checks metadata$isupdate = 'FALSE' before deleting.
3. Create a task and forget to resume it.
show tasks like 'MERGE_ORDERS';
+--------------+-----------+------------+----------------+
| name | state | schedule | warehouse |
|--------------+-----------+------------+----------------|
| MERGE_ORDERS | suspended | 5 minute | BOOKSHOP_WH |
+--------------+-----------+------------+----------------+
state = suspended, and task_history is empty. There is no error to find, which is exactly
what makes this failure mode expensive — check state first whenever a task appears not to
exist.
4. Build a two-task DAG and confirm the child skips when the parent fails.
| LOAD_STAGING | FAILED | ... | 100038 | Numeric value 'n/a' is not recognized |
| BUILD_MARTS | SKIPPED | NULL | NULL | NULL |
The mart was left holding yesterday’s correct data rather than being rebuilt from a failed
load. That is the same property dbt build gives you with SKIP, achieved here inside the
warehouse.
Next: roles, grants and secure sharing — who can see what, and how to share data without copying it.