How to Build Multi-Step Data Workflows Without Code
Here's the dirty truth about most production data workflows: they're 3-5 steps. Extract from a source. Transform something. Load it somewhere. Maybe branch on a condition. Send a notification. That's it.
And yet the default tooling for this — Airflow, Dagster, Prefect — asks you to learn a Python framework, set up a deployment pipeline, manage infrastructure, and write boilerplate that has nothing to do with the actual logic of your workflow. The gap between "I know what I want to happen" and "it's running in production" is measured in weeks, not minutes.
It doesn't have to be. The orchestration layer — the part that says "run step B after step A finishes, and only if step A returned rows" — doesn't need to be code. The individual steps might be SQL or Python, and that's fine. But the wiring between them? That's configuration, not programming.
What a real workflow looks like
Before we get into the how, let's be specific about what we're building. A revenue monitoring workflow that does this:
- Query — pull today's order totals from Postgres
- Calculate — compute daily revenue and compare to the 7-day moving average
- Branch — if revenue dropped more than 15%, continue; otherwise, stop
- Update — refresh the revenue dashboard with the new numbers
- Alert — send a Slack message to
#revenue-opswith the drop percentage and a link to the dashboard
Five steps. No complex DAG. No fan-out/fan-in. No backfill logic. This is the shape of 80% of data workflows I've seen in production, and it does not need a distributed scheduler to run.
Step 1: The SQL query
Every workflow starts with data. In Fastero's workflow builder, the first step is usually a SQL query against a connected source — Postgres, BigQuery, Snowflake, Redshift, MySQL.
SELECT
date_trunc('day', created_at) AS order_date,
SUM(total_amount) AS daily_revenue,
COUNT(*) AS order_count
FROM orders
WHERE created_at >= current_date
AND status IN ('completed', 'processing')
GROUP BY 1This runs against your database with read-only credentials. The result set — order_date, daily_revenue, order_count — becomes available as variables in every downstream step. That last part matters: step outputs flow forward automatically. No XCom, no artifact passing, no serialization gymnastics.
Fastero
Connect your database. Ask questions. Get dashboards.
Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.
Try free →Step 2: A Python step for the comparison
Some logic is easier in Python than SQL. Comparing today's revenue to a rolling average is one of those cases — you could do it in a CTE, but a short Python script is more readable and easier to modify later.
import pandas as pd
# `input_data` is automatically populated from the previous step's result
df = pd.DataFrame(input_data)
today_revenue = df['daily_revenue'].iloc[0]
# historical_data comes from a second SQL step (or a query within this script)
historical = pd.read_sql("""
SELECT SUM(total_amount) AS daily_revenue
FROM orders
WHERE created_at >= current_date - interval '7 days'
AND created_at < current_date
AND status IN ('completed', 'processing')
GROUP BY date_trunc('day', created_at)
""", connection)
avg_revenue = historical['daily_revenue'].mean()
pct_change = ((today_revenue - avg_revenue) / avg_revenue) * 100
# Output variables are available to downstream steps
output = {
'today_revenue': round(today_revenue, 2),
'avg_revenue': round(avg_revenue, 2),
'pct_change': round(pct_change, 1),
'is_drop': pct_change < -15
}The connection object is injected by the runtime — it uses the same database credentials you connected in step 1. No connection strings in your code, no secrets management to think about.
Step 3: Conditional branching
This is where orchestration frameworks usually force you to write a BranchPythonOperator or a custom sensor. In a visual workflow builder, it's a condition node: "If is_drop is true, continue to step 4. Otherwise, stop."
You configure this in the UI. The condition evaluates against the output variables from the previous step. No code required — though you can write a SQL or Python condition if the logic is more complex than a boolean check.
The branching step is where most data teams realize they've been over-engineering this. A condition that says "proceed if revenue dropped more than 15%" is three fields in a form: variable name, operator, value. Writing a BranchPythonOperator for this is like writing a Kubernetes manifest to run echo "hello".
Step 4: Dashboard refresh
Fastero dashboards can be refreshed programmatically as a workflow step — pass the dashboard ID and it re-runs the underlying queries with fresh data. If your dashboard lives outside Fastero (a Streamlit app, a Looker dashboard, a Google Sheet), this step becomes an API call instead.
# For external dashboards, hit their refresh API
import requests
requests.post(
'https://your-bi-tool.com/api/v1/dashboards/revenue-daily/refresh',
headers={'Authorization': f'Bearer {api_token}'},
timeout=30
)API call steps support GET, POST, PUT, and DELETE with custom headers, body templates, and timeout configuration. The response is captured as a step output, so you can check the status code in a downstream condition if the refresh might fail.
Step 5: Slack notification
The final step fires a Slack message — but only because the branch condition in step 3 allowed execution to reach this point. On a normal day where revenue is flat or up, the workflow stops at step 3 and Slack stays quiet.
The message template uses variables from earlier steps:
Revenue alert: daily revenue is ${today_revenue} (down ${pct_change}% vs. 7-day avg of ${avg_revenue}). Dashboard updated: [link]
No one gets alert fatigue because the notification only fires on actual drops. This is a problem I've seen with cron-based alerting scripts — they either alert every run (noisy) or someone hardcodes a threshold that goes stale within a month.
Triggers: what kicks it off
A workflow without a trigger is a workflow you have to remember to run manually. Fastero supports several trigger types, and this is where the "without code" part matters most — the trigger configuration is entirely visual.
Cron schedule — run the workflow at 9am every day. The most common trigger. Configured with a visual schedule picker, not a crontab expression (though you can write raw cron syntax if you prefer).
Database event — a Postgres LISTEN/NOTIFY fires when new rows land in a table. The workflow runs within seconds of the data changing, not at the next scheduled slot. Snowflake streams and BigQuery table update notifications work the same way.
Kafka message — a message on a specific topic triggers the workflow. Useful when your application already publishes events to Kafka and you want downstream analytics to react immediately.
Webhook — an inbound HTTP call triggers the workflow. Good for integrating with systems that support outbound webhooks (Stripe, Shopify, HubSpot) without writing a receiver.
For the revenue monitoring example, a cron trigger at 9am makes sense — you want the daily numbers after the previous day's orders have settled. But if you wanted near-real-time monitoring, you'd swap the cron trigger for a Postgres NOTIFY on the orders table and the workflow would run every time a batch of orders completes.
More on trigger configuration at /product/triggers.
Error handling without try/catch
Every step in a workflow can have a failure action: retry (with configurable delay and max attempts), skip and continue, or abort and notify. This is configured per step, not globally — because the right failure behavior is different for a SQL query (retry, the database might have been briefly unreachable) versus a Slack notification (skip, don't block the workflow because Slack is slow).
# Example: per-step error config (configured in the UI, shown here for clarity)
steps:
- name: pull_daily_orders
type: sql
on_failure:
action: retry
max_attempts: 3
delay_seconds: 30
then: abort_and_notify
- name: calculate_comparison
type: python
on_failure:
action: abort_and_notify
- name: send_slack_alert
type: notification
on_failure:
action: skipThe abort_and_notify action sends a failure notification to a channel you configure at the workflow level — usually a different channel than the business alert itself. Your #revenue-ops channel gets business alerts; your #data-engineering channel gets "the workflow that generates those alerts broke."
This separation matters. I've seen too many setups where the pipeline failure alert and the business metric alert go to the same channel, and people start ignoring all alerts because they can't tell which ones require action.
The Airflow/Dagster version of this
For comparison, here's what the same 5-step workflow looks like as an Airflow DAG:
from airflow import DAG
from airflow.operators.python import PythonOperator, BranchPythonOperator
from airflow.operators.empty import EmptyOperator
from airflow.providers.slack.operators.slack_webhook import SlackWebhookOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from datetime import datetime, timedelta
default_args = {
'owner': 'data-team',
'retries': 3,
'retry_delay': timedelta(minutes=5),
}
def pull_orders(**context):
hook = PostgresHook(postgres_conn_id='prod_db')
# ... query logic, push to XCom ...
def calculate_comparison(**context):
ti = context['task_instance']
data = ti.xcom_pull(task_ids='pull_orders')
# ... comparison logic, push result to XCom ...
def branch_on_drop(**context):
ti = context['task_instance']
result = ti.xcom_pull(task_ids='calculate_comparison')
return 'refresh_dashboard' if result['is_drop'] else 'skip'
with DAG('revenue_monitoring', default_args=default_args,
schedule_interval='0 9 * * *', start_date=datetime(2026, 1, 1)):
t1 = PythonOperator(task_id='pull_orders', python_callable=pull_orders)
t2 = PythonOperator(task_id='calculate_comparison', python_callable=calculate_comparison)
t3 = BranchPythonOperator(task_id='check_drop', python_callable=branch_on_drop)
t4 = PythonOperator(task_id='refresh_dashboard', python_callable=refresh_dashboard)
t5 = SlackWebhookOperator(task_id='send_alert', slack_webhook_conn_id='slack', message='...')
skip = EmptyOperator(task_id='skip')
t1 >> t2 >> t3 >> [t4 >> t5, skip]That's the skeleton. The actual functions (pull_orders, calculate_comparison, refresh_dashboard) still need to be written, each one dealing with XCom serialization, connection hooks, and task instance context. Then you need to deploy this DAG file to your Airflow instance, test it, and set up the Postgres and Slack connections in the Airflow admin UI.
None of this is wrong. It's just a lot of machinery for five steps that do something straightforward.
When visual workflows aren't enough
I'd be lying if I said visual workflow builders replace orchestration frameworks in every case. They don't. Here's where you'll outgrow them:
- Deep dependency graphs — if step 7 depends on steps 3, 4, and 5 (but not 6), and step 8 depends on steps 6 and 7, you're modeling a real DAG and a visual builder gets unwieldy. This is where Airflow's DAG model genuinely earns its complexity.
- Dynamic task generation — if the number of steps isn't known until runtime (e.g., "run this query for every table in the schema"), you need code-level control flow.
- Custom operators — if you need to interact with a system that doesn't have an API and requires a custom SDK integration, you'll need to write that integration somewhere.
But be honest about whether your workflow actually has these requirements. Most don't. Most are "query, transform, check, notify" — and for those, the orchestration boilerplate is pure overhead.
Putting it together
The revenue monitoring workflow we built takes about 10 minutes to configure in Fastero's workflow builder. That's writing the SQL, writing the Python comparison logic, setting the branch condition, choosing the Slack channel, and configuring the cron trigger. The orchestration — step ordering, variable passing, error handling, scheduling — is all configuration. No DAG files. No deployment pipeline. No infrastructure.
If you're already running workflows like this and want to see how the trigger system handles more complex patterns — event-driven activation, webhook chaining, cross-workflow dependencies — the triggers documentation covers those. And if your current pain point is more about the alerting side than the orchestration side, setting up SQL alerts without Datadog and getting Slack alerts on metric changes go deeper on that specific problem.
For the automation-of-reports angle — where the workflow's last step isn't an alert but a delivered report — automating SQL reports to Slack, email, or API walks through that pattern. And if the Python step in your workflow is the part that scares your ops team, running scheduled Python without Kubernetes explains what happens under the hood when Fastero executes your script.
Try Fastero free — build multi-step data workflows with SQL, Python, and visual orchestration in minutes. No credit card required.

