Core and provider operators, poke vs reschedule sensors, hooks and connection reuse, and how to write a custom operator that is worth writing.
⏱ 7 min readTopics chapter readerLevel · Medium
01 · Start here
Start here
⏱ 4 min · Topic 1 of 9
Chapter 3 named three ways to write a task. This chapter looks at each one in detail, and at the hook, which sits under them:
Part
Its job
Example
Operator
Does work.
Run a SQL query.
Sensor
Waits until something is ready.
Wait for the orders file.
Hook
Connects to an outside system.
A client for the shop database.
You will also build your own operator, from the file to the import line. Every output here comes from a real run with Airflow 3.1.
02 · The built-ins
The operators you will meet everywhere
⏱ 6 min · Topic 2 of 9
Five operators cover most of the code you will read. In Airflow 3, they come from provider packages:
Operator
It does
Package
PythonOperator
Runs a Python function.
standard
BashOperator
Runs a shell command on the worker.
standard
SQLExecuteQueryOperator
Runs SQL on any database connection.
common.sql
EmptyOperator
Nothing. It is a meeting point in the graph.
standard
TriggerDagRunOperator
Starts a run of another DAG.
standard
A provider package holds operators and hooks for one system, such as Postgres or AWS. Look for one before you write your own code for a system.
Some arguments accept Jinja, such as {{ ds }}, the day of the run. These arguments are the template fields of the operator. In our install they were:
Operator
Template fields
BashOperator
bash_command, env, cwd
PythonOperator
op_args, op_kwargs, templates_dict
SQLExecuteQueryOperator
sql, parameters, conn_id, database, hook_params
Jinja in any other argument stays as plain text, braces and all. Chapter 8 explains templates in full.
Watch out
Old tutorials use PostgresOperator or SnowflakeOperator. New code uses SQLExecuteQueryOperator with a conn_id. In new code, write @task instead of PythonOperator (chapter 7).
03 · Your own
Build your own operator
⏱ 6 min · Topic 3 of 9
Build an operator when the same block of code is in three or more DAGs, and only some values differ. For one use, a @task function is shorter.
Suppose many DAGs count the rows of a table for one day. Here is the operator. It lives in plugins/shop/operators.py:
plugins/shop/operators.pyworked example
Python
from airflow.sdk import BaseOperator
class CountRowsOperator(BaseOperator):
template_fields = ("day",)
def __init__(self, table: str, day: str, **kwargs):
super().__init__(**kwargs)
self.table = table
self.day = day
def execute(self, context):
print(f"counting {self.table} for {self.day}")
return 6
The plugins/shop folder also has an empty __init__.py, so Python treats it as a package. A real execute would run the count on the database.
The class has three parts, and each has one job:
Part
Job
template_fields
Lists the arguments that accept Jinja. Here, day.
__init__
Calls super().__init__(**kwargs) first, then stores the arguments.
execute(context)
Does the work. It runs only when the task runs.
Airflow puts the dags/, plugins/ and config/ folders on the Python path. So a DAG imports the operator like any Python module, with no extra setup:
{{ ds }} became the date of the run, 2026-01-21, because day is a template field.
To share an operator across many Airflow deployments, make it a real Python package, and install it in each image.
04 · The traps
What __init__ must not do
⏱ 5 min · Topic 4 of 9
Airflow builds every operator each time it parses the DAG file, by default every 30 seconds (chapter 3). So __init__ runs all day, even when no task runs.
That gives two rules. First, no database calls or API calls in __init__, because they would run on every parse. Second, do not change a template field in __init__:
A change that disappearsworked example
Python
class BadDayOperator(BaseOperator):
template_fields = ("day",)
def __init__(self, day: str, **kwargs):
super().__init__(**kwargs)
self.day = day.replace("-", "") # wrong place
def execute(self, context):
print(f"day is {self.day}")
With day="{{ ds }}", for 2026-01-21
output
day is 2026-01-21
We wanted 20260121. The dashes are still there.
In __init__, day is still the text {{ ds }}, which has no dashes to remove. Later, Airflow renders the template and writes the result onto self.day. Your change is lost.
So store template fields exactly as they arrive. Do the change at the start of execute, where the value is already rendered.
Watch out
Do not use a list or dict as a default value, such as columns=[]. Python makes it once, and every task then shares the same object. Use None, and make the list inside the function.
05 · Waiting
What waiting costs
⏱ 6 min · Topic 5 of 9
A sensor checks something again and again, such as whether a file exists. Between checks, it waits. How it waits is set by its mode:
Mode
While it waits
Use it when
poke (the default)
Holds a worker slot the whole time.
The wait is short, or it checks every few seconds.
reschedule
Frees the slot between checks.
It checks every minute or less often.
deferrable
Gives the wait to the triggerer, and uses no worker slot.
You run a triggerer, and the sensor supports it.
Here is one wait for the orders file, written in each mode:
One wait, three modesworked example
Python
# poke: holds a worker slot until the file comes
S3KeySensor(task_id="wait", bucket_key="s3://raw/{{ ds }}/orders.csv")
# reschedule: checks every 5 minutes, frees the slot in between
S3KeySensor(task_id="wait", bucket_key="s3://raw/{{ ds }}/orders.csv",
mode="reschedule", poke_interval=300, timeout=6 * 60 * 60)
# deferrable: the triggerer waits, no worker slot at all
S3KeySensor(task_id="wait", bucket_key="s3://raw/{{ ds }}/orders.csv",
deferrable=True, timeout=6 * 60 * 60)
S3KeySensor comes from the Amazon provider. poke_interval and timeout are in seconds, so this timeout is 6 hours.
Suppose 40 poke sensors wait for files, and you have 32 worker slots. Every slot holds a sleeping sensor, and no real work can start.
The workers look busy, so people add workers. But the new slots fill with sensors too. Changing the mode fixes it, with no new machines.
Watch out
deferrable=True needs a running triggerer. Without one, the task stays deferred, and nothing checks the file. Chapter 15 explains deferral.
06 · Failure
When the thing never arrives
⏱ 6 min · Topic 6 of 9
By default, a sensor gives up after 604800 seconds, which is 7 days. For a daily file, that is far too long. The runs behind it pile up, and nobody is told.
So set timeout on every sensor. Base it on when the business needs the data, not on the technical wait.
When the timeout ends, soft_fail decides what happens. We ran a sensor that never succeeds, with timeout=3, in both ways:
Setting
Sensor ends as
The next task ends as
soft_fail=False (default)
failed
upstream_failed
soft_fail=True
skipped
skipped
A failed sensor is easy to see, and failure alerts fire. A skipped sensor skips everything after it, with no failure anywhere. The run does nothing, and nobody is told.
Use soft_fail=True only when a missing file is normal. For example, a partner sends a file only on some days. Write that reason in a comment.
Interview question
A common question: what if the file never arrives? With no timeout of your own, it waits 7 days; with a timeout, it fails, and people see it. With soft_fail, it is skipped, and so is everything after it.
07 · Connecting
Hooks: the client underneath
⏱ 6 min · Topic 7 of 9
A hook is a client for one outside system. It finds the connection by its conn_id, logs in, and gives you methods such as get_records. Chapter 9 explains connections.
Most operators are a thin layer around a hook. So inside a @task, you can use the hook yourself, and add your own checks:
The same count, as an operator and as a hookworked example
Python
# Operator: short, when one query is all you need
SQLExecuteQueryOperator(
task_id="count_orders",
conn_id="shop_db",
sql="SELECT count(*) FROM orders WHERE order_date = '{{ ds }}'",
)
# Hook in a @task: when you need logic around the query
@task
def count_orders(ds=None):
hook = PostgresHook(postgres_conn_id="shop_db")
count = hook.get_first(
"SELECT count(*) FROM orders WHERE order_date = %s", parameters=(ds,)
)[0]
if count == 0:
raise ValueError(f"no orders for {ds}")
return count
Airflow fills ds when a @task has a parameter with that name. PostgresHook comes from the Postgres provider.
The operator cannot stop the run on zero orders. The hook version can, because it is ordinary Python.
SQL hooks share the same methods:
Method
Gives you
get_records(sql)
All rows, as a list of tuples.
get_first(sql)
The first row only.
get_df(sql)
A DataFrame.
run(sql)
Nothing. For writes and table changes.
insert_rows(table, rows)
Nothing. Inserts many rows.
Choose like this:
One standard step: an operator.
Logic around a call: a hook in a @task.
The same wrapper in three or more DAGs: your own operator.
08 · Recap
Recap
⏱ 4 min · Topic 8 of 9
Operators do, sensors wait, and hooks connect:
Look for a provider package before you write your own integration.
Your own operator: subclass BaseOperator, then super().__init__ first, store arguments, and work in execute.
Put it in plugins/, and import it like any module.
Never change a template field in __init__. Change it in execute.
For long waits, use reschedule or deferrable, and always set timeout.
Check yourself: you set self.table = table.upper() in __init__, and table is a template field. What does execute see? (Answer: the rendered value, not upper case.)
09 · Next Chapter
Next Chapter
You can now use operators, sensors and hooks, and build your own operator.
⏱ 3 min · Topic 9 of 9
Next chapter
TaskFlow API & Modern DAG Authoring
You can now use operators, sensors and hooks, and build your own operator.
Next, you write tasks as plain Python functions with @task, and connect them in shapes beyond a straight line.