Scheduling Recurring Data Pipelines for a Side Hustle
Your side hustle runs on overnight work: a nightly Shopify sync, a Monday-morning report rollup, a scraper that refreshes prices every six hours. Doing that by hand burns the margin you started the business for. This guide builds one idempotent pipeline function and then shows you the full spectrum of ways to run it on a schedule — OS cron with a Python entrypoint, APScheduler in-process, Celery beat for distributed workers, and managed serverless cron like Render cron and GitHub Actions — so you can pick the cheapest reliable option for your stage. Part of the Automating Side-Hustle Operations with APIs guide.
The hard part of scheduling is not "run this every night." It is running it exactly once, on the right clock, surviving restarts, and screaming when it fails. Get any one of those wrong and you either corrupt data, double-charge a customer, or discover three weeks later that your "nightly" job died silently on day two. This guide solves all four, and it treats scheduling as a commercial decision — the wrong tool at your stage either wastes money on idle infrastructure or costs you an afternoon debugging a duplicate-order incident. We build the pipeline once, then wire the same function into four different clocks so you can swap schedulers as the business grows without rewriting the work itself.
Prerequisites
You need Python 3.11+ and a pipeline that does real work — pulling data from a vendor API, transforming it, and writing it somewhere durable. The patterns here build directly on the Shopify orders to Google Sheets sync; think of that as the body of the function we are about to schedule. If your pipeline pulls from a source that has no API and you are scraping it, the same scheduling patterns apply — but read web scraping vs official APIs first, because a scraped source fails far more often than a documented API and your retry and alerting story has to be tighter.
Install the schedulers you intend to test:
pip install apscheduler celery "redis>=5" httpx python-dotenv
Set configuration in the environment — never in code:
PIPELINE_NAME=nightly-orders-sync
CRON_SCHEDULE=0 2 * * *
PIPELINE_TZ=Europe/Brussels
LOCK_PATH=/tmp/nightly-orders-sync.lock
LOCK_TTL_SECONDS=3600
SOURCE_API_URL=https://api.example.com/orders
SOURCE_API_KEY=replace-me
CELERY_BROKER_URL=redis://localhost:6379/0
Versions matter here more than usual. APScheduler 3.x and 4.x ship genuinely different APIs — 4.x moved to a data-store-backed engine and renamed the scheduler classes — so a tutorial written for one silently fails on the other. This guide uses 3.x, which is what ships stable today and what you will find in most production side-hustle stacks. Celery beat needs a broker such as Redis; without one the beat process starts but never delivers a task. Pin all three in requirements.txt and treat a scheduler upgrade as a real migration with a test run, not a passive pip install --upgrade.
Step 1 — Write one idempotent pipeline function
Everything downstream calls this. It reads config from the environment, does extract → transform → load, and is safe to run twice. Idempotency is what makes scheduling forgiving: a missed run, a manual re-run, or an overlapping trigger should never corrupt your data. It is also what lets you downgrade the guarantee your scheduler has to provide — and cheaper schedulers provide weaker guarantees.
import os
import time
import httpx
def run_pipeline() -> dict:
"""Idempotent extract-transform-load. Safe to run more than once."""
name = os.getenv("PIPELINE_NAME", "pipeline")
api_url = os.environ["SOURCE_API_URL"]
api_key = os.environ["SOURCE_API_KEY"]
started = time.perf_counter()
# Extract
with httpx.Client(timeout=30.0) as client:
resp = client.get(api_url, headers={"Authorization": f"Bearer {api_key}"})
resp.raise_for_status()
rows = resp.json().get("orders", [])
# Transform
cleaned = [
{"id": r["id"], "total": float(r["total_price"])}
for r in rows
if r.get("financial_status") == "paid"
]
# Load — keyed by id so re-running upserts instead of duplicating
upserted = upsert_by_id(cleaned)
return {
"pipeline": name,
"fetched": len(rows),
"loaded": upserted,
"duration_s": round(time.perf_counter() - started, 3),
}
The upsert_by_id helper is the single most important line in the file. In Postgres it is an INSERT ... ON CONFLICT (id) DO UPDATE; against a Google Sheet it is a set-difference against existing keys before you append. Whatever the backing store, it turns "run exactly once" — a distributed-systems problem nobody fully solves — into "run at least once," which every scheduler on earth can guarantee for free. If your load target is a real database, wire the upsert through an async session as described in async database access with SQLAlchemy so the pipeline shares the same connection pool as the rest of your app instead of opening a fresh connection every night.
One thing to resist: do not make the transform depend on when it runs. A pipeline that reads "yesterday" from the wall clock behaves differently on a re-run than on the original run, and that non-determinism is the enemy of idempotency. Pass the window in explicitly — a since timestamp derived from the last successful watermark you stored, not from datetime.now() — so a re-run of a missed night processes the night it missed, not the night you happen to re-run it.
Step 2 — Choose the scheduler that fits your stage
Before writing any scheduling code, decide which clock you actually need. The decision is not about which library is "best" — it is about whether you already pay for an always-on process, how precise your timing must be, and whether the job has to coordinate across more than one machine. Get this right and you avoid paying for infrastructure you do not use.
Read the tree from the top. Most side hustles land on the left two boxes and should stay there for a long time. You move right only when a concrete need forces it: minute-level precision (a price scraper that must beat a competitor's cache), or coordination across machines (you already run a fleet of Celery background workers and adding a second scheduler would be silly). The rest of this guide implements each leaf so you can see the real code behind the choice.
Step 3 — Run it in-process with APScheduler
For a single always-on container, APScheduler is the lightest option: no broker, no extra process. The AsyncIOScheduler fires your job on a cron expression inside your app's event loop, which makes it a natural fit when you have already deployed a FastAPI service and just want it to also do overnight work.
import asyncio
import os
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
async def scheduled_job() -> None:
# Run blocking pipeline off the event loop
result = await asyncio.to_thread(run_pipeline)
print(result)
async def main() -> None:
scheduler = AsyncIOScheduler(timezone=os.getenv("PIPELINE_TZ", "UTC"))
trigger = CronTrigger.from_crontab(
os.getenv("CRON_SCHEDULE", "0 2 * * *"),
timezone=os.getenv("PIPELINE_TZ", "UTC"),
)
scheduler.add_job(
scheduled_job,
trigger=trigger,
id="nightly-pipeline",
max_instances=1, # never overlap
coalesce=True, # collapse missed runs into one
misfire_grace_time=3600,
)
scheduler.start()
await asyncio.Event().wait() # keep the loop alive
if __name__ == "__main__":
asyncio.run(main())
Three keyword arguments carry the entire reliability story. max_instances=1 guarantees the job never overlaps with itself — critical when a slow night runs past the next trigger. coalesce=True collapses a backlog of missed triggers (the container was asleep) into a single catch-up run instead of firing six times in a row. misfire_grace_time=3600 says a trigger is still worth honouring up to an hour late, after which APScheduler quietly drops it rather than running a nightly job at noon. The asyncio.to_thread call matters just as much: run_pipeline uses a synchronous httpx.Client, and calling it directly on the event loop would freeze every other request your app is serving for the duration of the sync. Offloading it to a thread keeps the loop responsive.
APScheduler's weakness is that its schedule lives in memory. If the process dies, the schedule dies with it, and a job that was due while the process was down is gone unless coalesce and misfire_grace_time cover the gap. If you are weighing this against a worker-based scheduler, read APScheduler vs Celery beat before committing — it walks the durability trade-off in detail.
Step 4 — Run the same task from OS cron with a lock
If you do not want an always-on process, OS cron is free and battle-tested. It invokes a script on a schedule; you supply the entrypoint. The catch: cron will happily start a second copy while the first is still running, and unlike APScheduler it has no max_instances. A file lock supplies that guarantee.
# pipeline_entrypoint.py
import os
import sys
import fcntl
from contextlib import contextmanager
@contextmanager
def single_run_lock(path: str):
fd = open(path, "w")
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("Previous run still active; skipping.", file=sys.stderr)
sys.exit(0)
try:
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
fd.close()
if __name__ == "__main__":
lock_path = os.getenv("LOCK_PATH", "/tmp/pipeline.lock")
with single_run_lock(lock_path):
print(run_pipeline())
The crontab entry loads the environment and calls the entrypoint:
0 2 * * * cd /srv/app && /srv/app/.venv/bin/python pipeline_entrypoint.py >> /var/log/pipeline.log 2>&1
LOCK_EX | LOCK_NB means "grab the lock or fail immediately" — exactly what overlap protection requires. The flock is advisory and tied to the file descriptor, so it releases automatically if the process crashes; there is no stale lock to clean up by hand, which is the failure mode that plagues homegrown "write a PID file" schemes. Two footguns to remember. First, cron does not load your shell profile, so SOURCE_API_KEY will be empty unless you source an env file inside the script or set variables in the crontab itself. Second, flock on a local file only guards a single machine; if you run cron on two servers pointing at the same database, each has its own lock and both will run. The diagram below shows exactly what the lock buys you when a slow run overruns its next trigger.
Step 5 — Run it distributed with Celery beat
Once you already run background workers with Celery for webhook processing or long jobs, do not bolt on a second scheduler. Celery beat is a tick process that enqueues your task on a schedule; an existing worker executes it. The overlap guard now lives in the broker, so multiple workers across machines still run the job once — the exact guarantee flock cannot give you across servers.
import os
from celery import Celery
from celery.schedules import crontab
app = Celery("hustle", broker=os.environ["CELERY_BROKER_URL"])
@app.task(bind=True, max_retries=3)
def pipeline_task(self):
try:
return run_pipeline()
except Exception as exc:
raise self.retry(exc=exc, countdown=60)
app.conf.beat_schedule = {
"nightly-pipeline": {
"task": "pipeline_task",
"schedule": crontab(hour=2, minute=0),
}
}
app.conf.timezone = os.getenv("PIPELINE_TZ", "UTC")
Run celery -A app beat alongside celery -A app worker. You get free retries, failure isolation per task, and horizontal scaling — at the cost of running Redis and two long-lived processes. There is one operational trap worth stating plainly: run exactly one beat process. If you scale beat to two replicas by accident (easy to do with a naive Kubernetes deployment), every scheduled task fires twice, because beat has no leader election of its own. Keep beat at a single replica and let the workers scale. If Celery feels heavy for the amount of work you actually have, Celery vs RQ vs arq compares the lighter task queues that still give you a broker-backed schedule.
Step 6 — Use a managed scheduler (Render / Railway / GitHub Actions)
The cheapest option operationally is to let your platform own the clock. A managed cron service spins up your container, runs the entrypoint to completion, and shuts it down — you pay only for the seconds the job runs, and there is no process to keep alive, patch, or monitor for uptime.
Render and Railway both expose a cron job type that just needs a command and a schedule string; they run on the same platform where you already deploy your APIs, so there is no new vendor to onboard. For zero-infrastructure scheduling, GitHub Actions runs your pipeline on a schedule trigger:
name: nightly-pipeline
on:
schedule:
- cron: "0 2 * * *" # UTC only on GitHub Actions
workflow_dispatch: # manual trigger button
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install -r requirements.txt
- run: python pipeline_entrypoint.py
env:
SOURCE_API_URL: ${{ secrets.SOURCE_API_URL }}
SOURCE_API_KEY: ${{ secrets.SOURCE_API_KEY }}
GitHub Actions cron runs in UTC and can be delayed several minutes under load, so it suits nightly rollups and refreshes, not minute-precise jobs. The workflow_dispatch line gives you a manual run button in the Actions UI — invaluable for verification and for the "run it now, I need the report early" request that inevitably arrives. Two caveats specific to Actions: a schedule trigger on a repository with no activity for 60 days is automatically disabled, so a dormant side project quietly stops running; and the secrets you inject are per-repository, so rotating a source API key means updating it in one more place. If your pipeline calls an LLM or another metered vendor on a schedule, keep an eye on the bill — the patterns in controlling LLM API costs in production apply directly to a nightly job that quietly makes a thousand paid calls.
Step 7 — Log run status and retry transient failures
Every scheduled run must emit a machine-readable record of whether it succeeded. Without this you have no way to alert on silent failures, and a scheduled job you cannot see is a scheduled job you cannot trust.
import json
import logging
import sys
logger = logging.getLogger("pipeline")
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def run_and_log() -> int:
try:
result = run_pipeline()
logger.info(json.dumps({"event": "pipeline_ok", **result}))
return 0
except Exception as exc:
logger.error(json.dumps({"event": "pipeline_failed", "error": str(exc)}))
return 1
if __name__ == "__main__":
sys.exit(run_and_log())
Returning a non-zero exit code lets cron, Render, and GitHub Actions all mark the run as failed and trigger their own alerting — an emailed cron failure or a red Actions run is free monitoring you do not have to build. For richer pipelines, graduate to structured logging with structlog so every run emits a consistent JSON event you can query later. There is one distinction worth drawing: a transient failure (the source API returned a 503, the network blipped) deserves an in-run retry with backoff, while a permanent failure (bad credentials, a schema change) should fail loudly and stop. Do not paper over a permanent failure with retries; you will just burn three attempts and still fail, only later. Wrap the extract step with tenacity retries tuned to retry on connection errors and 5xx responses but not on a 401, and let the outer run_and_log catch what tenacity gives up on. Before you ship, cover the pipeline with a test that mocks the source API and asserts the upsert is idempotent — the approach in testing Python APIs with pytest works for a scheduled job just as well as for a request handler.
Configuration reference
| Env var | Default | Production recommendation |
|---|---|---|
CRON_SCHEDULE | 0 2 * * * | Off-peak hours; avoid 0 0 * * * rush. Quote it in YAML. |
PIPELINE_TZ | UTC | Pin a real zone (e.g. Europe/Brussels); GitHub Actions ignores it (UTC only). |
LOCK_PATH | /tmp/pipeline.lock | A durable path per pipeline; one lock file per distinct job. |
LOCK_TTL_SECONDS | 3600 | Slightly above your worst-case run time, so a crashed run self-clears. |
CELERY_BROKER_URL | — | A managed Redis URL; never the local default in production. |
SOURCE_API_KEY | — | Inject from a secret manager; never commit. |
Gotchas & failure modes
- Overlapping runs. A slow nightly job still running when the next trigger fires gives you two copies fighting over the same data. APScheduler's
max_instances=1, the OS-levelflock, or Celery's broker dedup each prevent this — pick one and actually wire it in. The idempotent upsert is your seatbelt if one slips through. - Timezone and DST.
0 2 * * *runs twice on the fall-back night and skips on spring-forward in a DST zone. Schedule in UTC, or pin an explicit IANA timezone in your scheduler so it handles the transition. Do not hand-roll offsets; they rot at the next DST boundary. - Missed runs on restart. If a deploy restarts your container at 2:00 AM, an in-process APScheduler job is simply gone. Use
coalesce=True+misfire_grace_time, or move the clock to a managed cron that does not depend on your process being up. - No alerting on failure. A job that fails silently for a week is worse than no job — you make decisions on stale data and never know. Always return a non-zero exit code and let the platform alert, or post to a webhook on the failure branch.
- Long jobs blocking everything. A multi-minute sync inside an
AsyncIOSchedulerblocks the event loop unless you offload it withasyncio.to_thread. If jobs routinely run long, that is the signal to move to Celery workers, not to add more threads. - Duplicate beat processes. Two Celery beat replicas fire every task twice. Keep beat at a single replica; scale the workers, never the scheduler.
Verification
Confirm the schedule without waiting until 2:00 AM. Trigger the pipeline manually and inspect the structured log:
PIPELINE_NAME=test python pipeline_entrypoint.py
# {"event": "pipeline_ok", "pipeline": "test", "fetched": 12, "loaded": 12, "duration_s": 0.84}
echo "exit code: $?" # expect 0
For APScheduler, log the next fire time on startup so you can eyeball that the cron expression parsed the way you meant:
job = scheduler.get_job("nightly-pipeline")
print("next run:", job.next_run_time)
On GitHub Actions, click Run workflow (the workflow_dispatch button) and confirm the run goes green. On Render or Railway, check the cron job's run history for a successful exit. The best verification is a deliberate failure: point SOURCE_API_URL at a URL that returns 500, run once, and confirm you actually get the alert. An untested alert path is the reason silent failures survive for a week.
Cost & performance note
The trade-off is always-on versus pay-per-run, and for a side hustle the numbers are not close. An always-on container with APScheduler costs the same whether it runs the job once a night or never — typically $5–7/month minimum on a small instance. Add Celery beat and you are running the app container plus a broker, so realistic all-in is $10–13/month before you have processed a single order. A managed serverless cron bills only for the seconds the job runs: a nightly five-minute pipeline on Render cron is roughly 150 minutes of compute a month, which lands in the low tens of cents; on GitHub Actions for a private repo it is a rounding error against the free minutes, and free on a public repo.
So for a side hustle running a handful of scheduled jobs, serverless cron wins decisively — the entire scheduling line item disappears into the noise of your other costs. Reach for an always-on scheduler only when you also need the process online for live traffic anyway (then APScheduler rides for free on infrastructure you already pay for), or when jobs run frequently enough — every few minutes — that repeated container cold starts cost more time and money than keeping one warm. As your automation grows past a couple of jobs into a real network of triggers and actions, the calculus shifts again toward building Zapier alternatives with Python, where a single always-on process orchestrating many workflows finally earns its monthly cost.
FAQ
Should I use cron or APScheduler for a side-hustle pipeline? If you do not already run an always-on process, use OS cron or a managed cron service — it is free, survives restarts, and needs no broker. Choose APScheduler only when you already have a long-running app where adding an in-process schedule avoids spinning up separate infrastructure, so the scheduler rides for free on compute you are already paying for.
How much does it cost to run a nightly pipeline? On managed serverless cron, cents or nothing: a five-minute nightly job is about 150 minutes of compute a month, which lands in the low tens of cents on Render and free on GitHub Actions for a public repo. An always-on APScheduler container is $5–7/month whether it runs or not, and a Celery beat stack with a broker is $10–13. At side-hustle volume the serverless options make the scheduling line item vanish.
How do I stop a scheduled job from running twice at once?
Use one overlap guard end to end: max_instances=1 in APScheduler, a non-blocking flock in an OS-cron entrypoint, or rely on Celery's broker so a single task is dequeued once. The bigger insurance is making the pipeline idempotent with an upsert so a rare double-run cannot corrupt data anyway — that lets you use the cheapest scheduler without fear.
Why did my nightly job not run after a deploy?
In-process schedulers lose their state on restart, so a job scheduled for a moment the container was down is simply missed. Set coalesce=True with a misfire_grace_time, or move scheduling to a managed cron that triggers independently of your app's uptime. On GitHub Actions specifically, a scheduled workflow is auto-disabled after 60 days of repository inactivity, which silently stops a dormant side project.
When is it worth migrating from cron to Celery beat?
When you already run Celery workers for other work, or when a single job needs to coordinate across multiple machines that a local flock cannot cover. Until then, Celery beat adds a broker, a second process, and a duplicate-beat footgun for no gain. Migrate for a concrete coordination or retry need, not because it feels more "production-grade."
Related
Same track:
- APScheduler vs Celery beat — the durability and complexity trade-off between the two in-code schedulers.
- Sync Shopify Orders to Google Sheets via API — the pipeline body this guide schedules.
- Building Zapier Alternatives with Python — where scheduling grows into a full workflow engine.
- Automating Side-Hustle Operations with APIs — the section overview for all of this.
Other tracks:
- Running Background Jobs with Celery — the worker layer Celery beat enqueues onto.
- Retrying Failed HTTP Requests with tenacity — backoff for the transient failures a scheduled extract will hit.
- Structured Logging with structlog — turn run-status logs into something you can query and alert on.