Data Engineering Module 8 – Orchestration: running pipelines on time
Dagster, Prefect, Kestra and cron: how they compare
How Apache Airflow 3, Dagster, Prefect, Kestra and cron differ: steps or data first, five criteria for a team, and one pipeline run in three of them.
What you will learn
- Compare task-centric and asset-centric orchestrators on one shared canteen pipeline
- Evaluate orchestrators on testing, local development, deployment and licence
- Pick an orchestrator for a team from its pipeline count, its unit of thought and who runs the servers
Before you start
What you’ll learn · Before you start
What you will learn
- Compare task-centric and asset-centric orchestrators on one shared canteen pipeline
- Evaluate orchestrators on testing, local development, deployment and licence
- Pick an orchestrator for a team from its pipeline count, its unit of thought and who runs the servers
Before you start
On this page
Three orchestrators run the same canteen pipeline in this lesson, in about 35 lines of Python each: Apache Airflow 3.3.2, Dagster 1.13.26 and Prefect 3.8.8. In the last-month counts the first example reads, their packages had 7.2, 7.9 and 6.9 million PyPI downloads, and they and a fourth orchestrator, Kestra 2.0.7, all carry the Apache License 2.0.
Dagster models a pipeline as the data it keeps up to date, while Airflow, Prefect and Kestra model the steps that build that data. Airflow and Prefect write those steps in Python; Kestra writes them in YAML for its own Java server. Cron, the baseline, only starts each command on time. The shared pipeline is the nightly job of Campus Bite, this track’s invented food-ordering platform: read raw orders, drop the cancelled ones and sum revenue per canteen.
Task-centric tools think in steps, asset-centric tools in data
Two mental models split the A tool that runs a graph of steps on a schedule, in order, with retries, backfills and a record of each run. of this lesson. In a task-centric orchestrator: An orchestrator that models a pipeline as a graph of steps and the order they run in, with schedules and retries set on that graph and its steps. Apache Airflow, Prefect and Kestra work this way. the unit of thought is a step: a function, a shell command, a SQL query. The pipeline is a graph of steps and their order, with its schedule and retries set on that graph and its steps. Airflow, Prefect and Kestra sit on this side. Cron is its bare end: it knows only when each command starts, and leaves job order, retries and reruns of past days to the scripts (Orchestration concepts). In an asset-centric orchestrator: An orchestrator that models a pipeline as a graph of the data products it keeps up to date, called assets, and derives the steps to run from that graph. Dagster is built this way. the unit is the data each step produces. The pipeline is a graph of data products, called assets, and the steps that build them follow from that graph; schedules, sensors and automation conditions decide when an asset is brought up to date (Dagster: automation). Dagster was built on this model, and Airflow 3’s assets bring part of it to a task graph. Both models can express the same pipeline, but what you monitor, test and reason about differs.
The figure below scores cron and the four orchestrators on five criteria; Kestra’s cells come from its documentation, since the lesson does not run it. Answer the three questions about your team and the figure rules out what does not fit, with the reason:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Option | Run on a laptop | Testing story | Scheduling shapes | Lineage view | Open-source licence |
|---|---|---|---|---|---|
| Cron plus a script Looks at: one Python script on a Linux box, timed by cron | Good one Python command; no server, no state outside the script | Mixed unit-test the script; cron itself has no test harness | Mixed one cron line; no backfills, no data triggers | Poor no record of which output came from which input | Good part of the operating system (POSIX crontab); nothing new to license |
| Apache Airflow 3 Looks at: a DAG folder, a scheduler and a web UI | Good airflow dags test: one process and a SQLite file | Good airflow dags test runs a DAG without a scheduler | Good cron, timetables, data intervals and asset triggers | Good assets and task graph in the UI; data intervals on each run | Good Apache License 2.0, Apache Software Foundation |
| Dagster Looks at: a Definitions object of assets and their lineage | Good dagster dev starts the UI and the daemon on SQLite storage | Good call an asset as a Python function in a test | Good schedules, sensors and automation conditions on assets | Good the asset graph is the model, and the UI shows it | Good Apache License 2.0, Dagster Labs |
| Prefect Looks at: a Python flow of tasks, run from a deployment | Good python -m runs a flow; a temporary server starts beside it | Good prefect_test_harness gives tests a temporary database | Good cron, interval, RRule and event-driven deployment triggers | Mixed flows and tasks first; asset lineage needs Prefect Cloud | Good Apache License 2.0, Prefect Technologies |
| Kestra Looks at: YAML flows run by its own Java server | Mixed one docker run starts its Java server, the UI and an H2 database | Mixed run flows on a local server; flow unit tests need Enterprise or Cloud | Good schedule (cron), flow, webhook, polling and realtime triggers | Mixed tasks first; asset lineage needs the Enterprise Edition or Cloud | Good Apache License 2.0 for the open-source edition, Kestra |
How the answers decide
- “How many scheduled pipelines, roughly, will one team own in a year from now?” Many (more than 10): rules out Cron plus a script. Cron only starts each script on time: past ten pipelines, retries, backfills, lineage and one view of every run all become code your team writes and keeps.
- “How many scheduled pipelines, roughly, will one team own in a year from now?” One or two: adds a caveat to Apache Airflow 3, Dagster, Prefect and Kestra. For one or two scripts a server, its database and a web UI are overhead; cron plus a tested script often fits better.
- “Do you reason about the data each step produces, or about the steps themselves?” The data (freshness, lineage): adds a caveat to Cron plus a script, Prefect and Kestra. Cron has no lineage; Prefect and Kestra model flows of tasks first, and their asset lineage needs Prefect Cloud or Kestra's Enterprise Edition.
- “Do you reason about the data each step produces, or about the steps themselves?” The steps (what runs when): adds a caveat to Dagster. Dagster asks you to model data products; a step-first team can use it, but the mental shift is real.
- “Who runs the server and the schedulers?” Nobody can run infrastructure: adds a caveat to Cron plus a script, Apache Airflow 3, Dagster, Prefect and Kestra. A team that cannot run infrastructure needs a hosted service of one of these engines; even cron needs a machine that someone keeps.
The licence column never settles it; the pipeline count, the unit of thought and who runs the servers do. Kestra 2.0.7 sits on the task-centric side but defines its flows in YAML rather than Python (Kestra: flows), and brings its own scheduler and UI in a server written in Java; its open-source edition carries the Apache License 2.0 (Kestra on GitHub). Dagster+ and Prefect Cloud are hosted services of two of these engines; this lesson stays on the open-source cores.
Key idea
Task-centric tools (Airflow, Prefect, Kestra) model the pipeline as a graph of steps, and cron only times each command; asset-centric tools (Dagster, and Airflow’s assets) model the data products the pipeline keeps up to date, and derive the steps from that graph.
Checkpoint
Check: Task-centric and asset-centric orchestrators
What the download numbers can and cannot say
The counts of the opening are PyPI Stats’ last-month figures, and the versions and licences come from PyPI’s JSON API, Kestra’s from its GitHub release and repository. Both sit in two small files beside this lesson, so the script below prints the same table on any machine:
"""Print each orchestrator's saved version, licence and last-month PyPI downloads.
The script reads two JSON files saved beside it, so the table is the same on
every run and needs no network:
- inputs/pypi_releases.json: each package's version and licence, from
pypi.org/pypi/<name>/json (info.version, and info.license_expression or
info.license). Kestra's server is a Java application, not a PyPI package:
its version is its newest stable release on github.com/kestra-io/kestra
and its licence is the repository's. (The PyPI package named kestra is
Kestra's Python client, so its downloads would not count the server.)
- inputs/pypi_recent.json: data.last_month from
pypistats.org/api/packages/<name>/recent, the package's downloads in the
last month.
"""
from __future__ import annotations
import json
from pathlib import Path
HERE = Path(__file__).parent
RELEASES = json.loads((HERE / "inputs" / "pypi_releases.json").read_text())
RECENT = json.loads((HERE / "inputs" / "pypi_recent.json").read_text())
NAMES = ["apache-airflow", "dagster", "prefect", "kestra"]
def downloads(name: str) -> str:
if name not in RECENT:
return "server not on PyPI"
return f"{RECENT[name]:,}"
def main() -> None:
rows = [("Package", "Version", "Last-month downloads", "Licence")]
for name in NAMES:
release = RELEASES[name]
rows.append((name, release["version"], downloads(name), release["licence"]))
widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]))]
print("| " + " | ".join(cell.ljust(w) for cell, w in zip(rows[0], widths)) + " |")
print("|" + "|".join("-" * (w + 2) for w in widths) + "|")
for row in rows[1:]:
print("| " + " | ".join(cell.ljust(w) for cell, w in zip(row, widths)) + " |")
licences = sorted({RELEASES[name]["licence"] for name in NAMES})
print()
if len(licences) == 1:
print(f"All {len(NAMES)} carry one licence: {licences[0]}")
else:
print("Licences: " + ", ".join(licences))
if __name__ == "__main__":
main() Output
| Package | Version | Last-month downloads | Licence | |----------------|---------|----------------------|------------| | apache-airflow | 3.3.2 | 7,196,244 | Apache-2.0 | | dagster | 1.13.26 | 7,886,605 | Apache-2.0 | | prefect | 3.8.8 | 6,895,663 | Apache-2.0 | | kestra | 2.0.7 | server not on PyPI | Apache-2.0 | All 4 carry one licence: Apache-2.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pypi_stats_session.py
In that month the three Python packages were within about one million downloads of each other. Kestra’s row has no count because its server is a Java application released on GitHub; the PyPI package named kestra is its Python client (PyPI: kestra). The Python Packaging User Guide adds that pip’s cache, mirrors and inflation scripts skew such counts, and that many downloads do not make a project good (Python Packaging User Guide). Close counts say only that all three are widely installed.
Practise
The exercise holds the canteen job twice, as written-down steps and as asset functions that only name what they read: derive the run order from those reads.
Exercise · Easy · Python
Derive the canteen job's run order from its data
The starter holds the Campus Bite nightly job twice, on the same five orders. TASKS is the task-centric view: three steps, in the order someone wrote down, each naming the data product it builds. ASSETS is the asset-centric view: three functions, each named after the product it builds and each naming the products it reads as its parameters. Nobody wrote the asset view's order down; it has to follow from those reads.
- **
run_order(assets)** returns the list of product names in the order the dictionary happens to hold them, socanteen_revenuecomes before the totals it reads. Return every name once, each one after every product it reads.reads(build)already gives a function's parameter names. Visit the names in sorted order, so two runs always give the same list, and raiseValueErrorwhen the reads go round in a circle. - **
materialise(assets)** builds the products in the dictionary's order, so it asks for a value before it exists. Build each product once, in run order, passing it the values of the products it reads, and return a dictionary from each name to its value.
The sample tests check that the derived order matches the task view's written one, that the revenue comes out at 150 rupees for each canteen, that two new products find their own places without anyone writing an edge, and that a circle of reads is refused. They use only the standard library, so they run the same way in the page and on your own machine.
Starter code · canteen_views.py
"""Starter for the asset-run-order exercise.
The Campus Bite nightly job written two ways, on the same five orders.
TASKS is the task-centric view: three steps in the order someone wrote down,
each with the data product it builds. ASSETS is the asset-centric view: each
product and the function that builds it, and each function names the
products it reads as its parameters. Change run_order() so it derives the
order from those reads, and materialise() so it builds every product once,
in that order.
"""
from __future__ import annotations
import inspect
RAW = [
{"canteen": "North Block", "amount_inr": 90, "status": "delivered"},
{"canteen": "North Block", "amount_inr": 60, "status": "delivered"},
{"canteen": "North Block", "amount_inr": 55, "status": "cancelled"},
{"canteen": "Hostel 3", "amount_inr": 80, "status": "delivered"},
{"canteen": "Hostel 3", "amount_inr": 70, "status": "delivered"},
]
# The task-centric view: each step and the product it builds, in the order written by hand.
TASKS = [
("extract", "raw_orders"),
("transform", "delivered_totals"),
("load", "canteen_revenue"),
]
def raw_orders() -> list[dict]:
return list(RAW)
def delivered_totals(raw_orders: list[dict]) -> dict[str, int]:
totals: dict[str, int] = {}
for row in raw_orders:
if row["status"] == "delivered":
totals[row["canteen"]] = totals.get(row["canteen"], 0) + row["amount_inr"]
return totals
def canteen_revenue(delivered_totals: dict[str, int]) -> list[dict]:
return [{"canteen": c, "revenue_inr": t} for c, t in sorted(delivered_totals.items())]
# The asset-centric view: each product and the function that builds it, in no particular order.
ASSETS = {
"canteen_revenue": canteen_revenue,
"raw_orders": raw_orders,
"delivered_totals": delivered_totals,
}
def reads(build) -> list[str]:
"""The products a build function reads: its parameter names."""
return list(inspect.signature(build).parameters)
def run_order(assets: dict) -> list[str]:
"""Every product name once, each one after every product it reads."""
return list(assets)
def materialise(assets: dict) -> dict:
"""Build every product once, in run order, passing each the values it reads."""
values: dict = {}
for name, build in assets.items():
values[name] = build(*[values[upstream] for upstream in reads(build)])
return values The sample tests · test_canteen_views.py
"""Sample tests for the asset-run-order exercise.
The derived order must match the task view's written order, the products must
come out right, new products must find their own places from what they read,
and a circle of reads must be refused.
"""
from __future__ import annotations
from canteen_views import ASSETS, TASKS, materialise, reads, run_order
def placed_after_their_reads(order: list[str], assets: dict) -> bool:
return all(order.index(upstream) < order.index(name) for name in assets for upstream in reads(assets[name]))
def test_the_derived_order_matches_the_written_one():
"""The asset view derives the order the task view wrote by hand."""
written = [product for _step, product in TASKS]
derived = run_order(ASSETS)
assert derived == written, f"the reads give {written}, not {derived}"
def test_materialise_builds_the_revenue_rows():
"""Each canteen's delivered orders add up to 150 rupees; the cancelled 55 is left out."""
values = materialise(ASSETS)
assert values["delivered_totals"] == {"North Block": 150, "Hostel 3": 150}, (
f"delivered_totals is {values.get('delivered_totals')}: only delivered orders count"
)
assert values["canteen_revenue"] == [
{"canteen": "Hostel 3", "revenue_inr": 150},
{"canteen": "North Block", "revenue_inr": 150},
], f"canteen_revenue is {values.get('canteen_revenue')}"
assert sorted(values) == sorted(ASSETS), "materialise must return one value for every product"
def test_new_products_find_their_own_places():
"""Two new products join without anyone writing an edge; each lands after what it reads."""
def top_canteen(canteen_revenue: list[dict]) -> str:
return max(canteen_revenue, key=lambda row: (row["revenue_inr"], row["canteen"]))["canteen"]
def order_count(raw_orders: list[dict]) -> int:
return len(raw_orders)
assets = {"top_canteen": top_canteen, **ASSETS, "order_count": order_count}
order = run_order(assets)
assert sorted(order) == sorted(assets), f"run_order must list each of the 5 products once, not {order}"
assert placed_after_their_reads(order, assets), f"in {order} a product comes before something it reads"
assert order == run_order(dict(reversed(list(assets.items())))), (
"the order must not depend on the order of the dictionary: visit the names sorted"
)
values = materialise(assets)
assert values["order_count"] == 5, f"order_count is {values.get('order_count')}, not the 5 raw orders"
assert values["top_canteen"] == "North Block", f"top_canteen is {values.get('top_canteen')}"
def test_each_product_is_built_once():
"""A product read by two others is still built once."""
calls: list[str] = []
def base() -> int:
calls.append("base")
return 2
def double(base: int) -> int:
calls.append("double")
return base * 2
def total(base: int, double: int) -> int:
calls.append("total")
return base + double
values = materialise({"total": total, "double": double, "base": base})
assert values == {"base": 2, "double": 4, "total": 6}, f"the values are {values}"
assert sorted(calls) == ["base", "double", "total"], f"the builds were {calls}: each product once"
def test_a_circle_of_reads_is_refused():
"""Two products that read each other have no run order."""
def first(second: int) -> int:
return second
def second(first: int) -> int:
return first
try:
run_order({"first": first, "second": second})
except ValueError:
return
raise AssertionError("a circle of reads must raise ValueError, not return an order") A hint
Place a product only after the products it reads are placed. A small recursive visit(name) does it: if the name is already placed, stop; otherwise visit each name in reads(assets[name]) first, then append the name. Keep a second set of the names you are still inside: meeting one of them again means the reads form a circle. materialise can then walk run_order(assets) and call each function with values[upstream] for every upstream name it reads.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Six questions then check the two models, the figure and the download numbers.
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
The same canteen pipeline, in Airflow, Dagster and Prefect Pro: opens with a Pro, Premium or Ultimate pass
The three Python tools write the Campus Bite daily job in about 35 lines each. The code sits beside this lesson in
examples/, and every output below comes from a real run on the pinned versions; each driver drops the tool’s
house-keeping lines and masks times, ids and run names, so two runs print byte-identical text.
In this part:
- An Airflow TaskFlow DAG with retries and a daily schedule
- Dagster assets, materialised by name
- A Prefect flow of three task decorators
3 runnable examples (airflow_canteen_session.py, airflow_session.py, canteen_flow.py, canteen_pipeline/__init__.py, compare_session.py, dags/canteen_daily.py, dagster_canteen_session.py, prefect_canteen_session.py) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Deployment, testing and local development compared Pro: opens with a Pro, Premium or Ultimate pass
One loop runs every day on an orchestrator team’s laptops: write a change, run it locally, ship it to a server that runs it on a schedule. The three tools take a different shape at each stage of that loop, and that shape often decides which one fits a team.
In this part:
- The develop, test and deploy loop in each tool
- The minimum run, the tests and the deploy
- Who wrote each line of the three canteen transcripts
- Key takeaways from these parts
1 diagram · 1 runnable example (Python) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Warm-up (fresher to 2-5 years): What makes four orchestrators the same, and where do they differ?
All four run a graph of work on a schedule, in order, with retries you can set and a record of every run; cron only starts each command on time. They differ in the unit of thought: Airflow, Prefect and Kestra model the steps and their order, while Dagster models the data products and derives the steps from them. They also differ in how a pipeline is written: in Python for three of them, and in YAML for Kestra, which runs it on its own Java server.
Answer each prompt below before you open its first step.
-
Mid to staff
A team runs the canteen job as an Airflow 3 DAG of three tasks, and the business now asks only whether the canteen revenue table is fresh. Would you port the job to Dagster assets or declare an asset inside Airflow 3? Say what changes in the code, in the run record and on the on-call's first screen.
-
Mid to staff
How would you set up a laptop to develop and test a daily pipeline on Airflow 3, Dagster and Prefect without starting a web server or a scheduler, and where do the three paths differ?
-
Mid to staff
A team of three analysts and one engineer runs 15 cron jobs on one Linux box they look after themselves, and their on-call reads a failed job's output first. Which of Airflow 3, Dagster and Prefect would you put on that box, and what must keep running for it?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do task-centric and asset-centric orchestrators differ?
Task-centric tools, Airflow, Prefect and Kestra, model a pipeline as a graph of steps. Asset-centric tools, Dagster and Airflow's assets, model the data products and derive the steps from them.
-
Where does cron sit beside the orchestrators?
At the bare end of the task-centric side: it only starts each command on time, and job order, retries and reruns of past days are left to the scripts.
-
How does Kestra differ from Airflow and Prefect?
It is task-centric as well, but its flows are written in YAML rather than Python and run on its own scheduler, UI and server written in Java.
-
What do Airflow, Dagster, Prefect and Kestra all do that cron does not?
Each runs a graph of work on a schedule, in order, with retries you can set and a record of every run; cron only starts each command on time.
-
Can a licence or a download count pick the orchestrator for you?
No. All four carry the Apache License 2.0, and the three Python packages were within about a million PyPI downloads of each other in the saved month. Caches, mirrors and scripts also skew such counts.
-
What does decide between cron and the four orchestrators?
How many pipelines you run, the unit you think in and who runs the servers. Cron with a tested script fits one or two jobs, a team that cannot run servers needs a hosted service, and a team that reasons about data leans to Dagster or Airflow's assets.
Key takeaways
- Task-centric tools (Airflow, Prefect, Kestra) model a pipeline as a graph of steps and cron only starts each command; asset-centric tools (Dagster, and Airflow’s assets) model the data products and derive the steps from them.
- All four orchestrators carry the Apache License 2.0, and in the saved month the three Python packages were within about one million PyPI downloads of each other, so neither the licence nor the counts pick the tool.
- In the figure, cron plus a tested script fits one or two jobs and drops out past ten pipelines; a team that cannot run servers needs a hosted service, and a team that reasons about data leans towards Dagster or Airflow’s assets.
Terms in this lesson
- asset-centric orchestrator
- An orchestrator that models a pipeline as a graph of the data products it keeps up to date, called assets, and derives the steps to run from that graph. Dagster is built this way.
- task-centric orchestrator
- An orchestrator that models a pipeline as a graph of steps and the order they run in, with schedules and retries set on that graph and its steps. Apache Airflow, Prefect and Kestra work this way.
References
- Apache Airflow 3.3.2 documentation (Apache Software Foundation)
- Apache Airflow 3.3.2: asset scheduling (Apache Software Foundation)
- Apache Airflow 3.3.2: architecture overview (Apache Software Foundation)
- Apache Airflow 3.3.2: quick start (Apache Software Foundation)
- Dagster home (Dagster Labs)
- Dagster: get started (Dagster Labs)
- Dagster: defining assets (Dagster Labs)
- Dagster: logging (Dagster Labs)
- Dagster 1.13.26 source: the asset materialize command (Dagster Labs)
- Prefect v3: get started (Prefect Technologies)
- Prefect v3: flows (Prefect Technologies)
- Prefect v3: tasks (Prefect Technologies)
- Prefect v3: deployments (Prefect Technologies)
- Kestra documentation (open-source core) (Kestra)
- Kestra quickstart (Kestra)
- Kestra: flows (Kestra)
- Kestra: triggers (Kestra)
- Kestra Enterprise Edition and Cloud: unit tests (Kestra)
- Kestra Enterprise Edition and Cloud: assets (Kestra)
- Kestra v2.0.7 release (Kestra)
- Kestra source repository (Apache-2.0 licence) (Kestra)
- Dagster: automation (Dagster Labs)
- Dagster: the dagster-daemon (Dagster Labs)
- Dagster: unit testing assets and ops (Dagster Labs)
- Dagster: op retries (Dagster Labs)
- Prefect v3: schedules (Prefect Technologies)
- Prefect v3: assets (Prefect Technologies)
- Prefect v3: retries (Prefect Technologies)
- Prefect v3: settings reference (Prefect Technologies)
- Prefect v3: test workflows (Prefect Technologies)
- Apache Airflow 3.3.2: configuration reference (Apache Software Foundation)
- Apache Airflow 3.3.2: best practices (Apache Software Foundation)
- PyPI: apache-airflow (Python Software Foundation)
- PyPI: dagster (Python Software Foundation)
- PyPI: prefect (Python Software Foundation)
- PyPI: kestra (the Kestra Python client) (Python Software Foundation)
- PyPI Stats: recent downloads (Python Software Foundation (PyPI Stats))
- Python Packaging User Guide: analyzing PyPI package downloads (Python Packaging Authority)
Report a problem with this lesson
Kept only in this browser. Your Learn progress