Scripts¶
Custom Python code that runs on a schedule or manually via "Run Now" in the Scripts tab.
What Are Scripts?¶
Scripts are Python files in scripts/ that execute as subprocesses for custom logic — email reports, Slack alerts, API exports, or any other Python code. Unlike notebooks, scripts are scheduled, manual, headless, and repeatable.
Getting Started¶
1. Create a Script¶
Place a .py file in the scripts/ directory:
2. Write Some Code¶
Open scripts/my_report.py and add:
"""scripts/my_report.py
Example: Print a greeting to verify the execution environment.
"""
import os
project_root = os.environ.get("DANGO_PROJECT_ROOT", "unknown")
print(f"Hello from Dango! Project root: {project_root}")
3. Run It¶
The script appears automatically in the Scripts tab of the Web UI:
- Navigate to Scripts in the top navigation bar
- Find
my_report.pyin the table - Click Run Now
- The Status column shows the current state (running, success, failed)
- Click the status badge or View Log to inspect stdout and stderr
Script Discovery
Dango recursively scans the scripts/ directory and displays all .py files. Files prefixed with _ or ., and files named __init__.py, are hidden. You can organize scripts into subdirectories (e.g., scripts/reports/daily.py).
Execution Environment¶
Scripts run as isolated subprocesses with the following environment:
| Property | Value |
|---|---|
| Python interpreter | sys.executable — the same Python that runs Dango (your venv) |
| Working directory | Your project root (DANGO_PROJECT_ROOT) |
| Environment variables | Inherited from the Dango process + DANGO_PROJECT_ROOT |
Available Environment Variables¶
| Variable | Description | Set By |
|---|---|---|
DANGO_PROJECT_ROOT | Absolute path to project root | Dango automatically |
DANGO_SCRIPT_RUN_ID | Unique run identifier (manual runs only) | Dango automatically |
Any .env var | All variables from your .env file | Inherited from Dango process |
Setting Environment Variables¶
Dependencies¶
If your script needs packages beyond the Python standard library, create scripts/requirements.txt:
Dango automatically installs these dependencies:
- When the platform starts (
dango startordango serve) - When a script schedule is activated (via
dango schedule add) - During cloud deployment (
dango deployordango remote push)
Installation Failures
If pip install -r scripts/requirements.txt fails, Dango logs a warning but does not block startup. Check .dango/logs/activity.jsonl for details. The installation runs with a 120-second timeout.
DuckDB Access¶
Your warehouse is at data/warehouse.duckdb. Unless your script performs writes, connect in read-only mode:
import os
import duckdb
project_root = os.environ["DANGO_PROJECT_ROOT"]
db_path = os.path.join(project_root, "data", "warehouse.duckdb")
# Read-only connection (preferred for queries)
conn = duckdb.connect(db_path, read_only=True)
rows = conn.sql(
"SELECT table_name FROM information_schema.tables"
).fetchall()
conn.close()
Writing to DuckDB¶
If your script needs to write to the warehouse, acquire DbtLock to coordinate with other writers:
"""scripts/import_data.py
Example: Safely write data to the warehouse.
"""
import os
from pathlib import Path
import duckdb
from dango.utils.dbt_lock import DbtLock
project_root = Path(os.environ["DANGO_PROJECT_ROOT"])
db_path = project_root / "data" / "warehouse.duckdb"
lock = DbtLock(project_root)
try:
lock.acquire(timeout=30)
conn = duckdb.connect(str(db_path))
conn.sql("CREATE SCHEMA IF NOT EXISTS script_data")
conn.sql("""
CREATE TABLE IF NOT EXISTS script_data.my_import (
id INTEGER PRIMARY KEY,
value VARCHAR,
loaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.sql(
"INSERT INTO script_data.my_import VALUES (1, 'hello', CURRENT_TIMESTAMP)"
)
conn.commit()
conn.close()
print("Data written successfully.")
finally:
lock.release()
DbtLock is Not Auto-Acquired
Scripts are not automatically wrapped in a DbtLock. If your script writes to DuckDB, you must acquire the lock yourself.
Anti-Patterns¶
Don't Create Tables in DuckDB Directly¶
Use dbt models for persistent tables. Script-written tables are invisible to dbt and can't participate in transformations.
# BAD: creating tables directly in scripts
conn.sql("CREATE TABLE raw.my_table AS SELECT ...")
# GOOD: define a dbt model in models/
# models/script_models/my_table.sql
# {{ config(materialized='table') }}
# SELECT ...
Don't Acquire DbtLock for the Entire Script¶
Acquire the lock only around write operations. Long-held locks block syncs and dbt runs.
# BAD: holding the lock during slow network I/O
lock.acquire()
data = fetch_from_api() # slow!
conn.sql("INSERT INTO ...")
# GOOD: lock only around writes
data = fetch_from_api()
lock.acquire()
conn.sql("INSERT INTO ...")
lock.release()
Don't Import Dango Internals¶
Only use dango.utils.dbt_lock.DbtLock. Internal modules can change without notice.
# BAD: importing internal modules
from dango.config.loader import ConfigLoader # internal API
# ACCEPTABLE: using the public lock utility
from dango.utils.dbt_lock import DbtLock
Examples¶
Example 1: Email Report¶
This script queries a daily summary from DuckDB and emails it via SMTP. It uses only the Python standard library — no additional dependencies.
"""scripts/email_report.py
Send a daily summary email via SMTP.
"""
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path
import duckdb
project_root = Path(os.environ["DANGO_PROJECT_ROOT"])
db_path = project_root / "data" / "warehouse.duckdb"
smtp_host = os.environ.get("SMTP_HOST", "localhost")
smtp_port = 587
try:
smtp_port = int(os.environ.get("SMTP_PORT", "587"))
except (ValueError, TypeError):
pass
smtp_user = os.environ.get("SMTP_USER", "")
smtp_pass = os.environ.get("SMTP_PASS", "")
recipient = os.environ.get("REPORT_RECIPIENT", "[email protected]")
conn = duckdb.connect(str(db_path), read_only=True)
rows = conn.sql("""
SELECT o.order_date,
COUNT(*) AS order_count,
SUM(o.total) AS revenue
FROM raw.orders o
WHERE o.order_date >= CURRENT_DATE - INTERVAL '1' DAY
GROUP BY o.order_date
ORDER BY o.order_date
""").fetchall()
conn.close()
if not rows:
print("No orders found for the last 24 hours.")
else:
body_lines = ["Daily Order Summary", "=" * 40, ""]
for date, count, revenue in rows:
body_lines.append(
f"Date: {date} Orders: {count} Revenue: ${revenue:.2f}"
)
msg = EmailMessage()
msg.set_content("\n".join(body_lines))
msg["Subject"] = f"Daily Report - {rows[0][0]}"
msg["From"] = smtp_user
msg["To"] = recipient
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
print(f"Report sent to {recipient}")
Example 2: Slack Alert¶
This script checks if a key model has fresh data and sends an alert to Slack if stale. Uses only the standard library.
"""scripts/slack_alert.py
Check data freshness and alert Slack if stale.
"""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
import duckdb
project_root = Path(os.environ["DANGO_PROJECT_ROOT"])
db_path = project_root / "data" / "warehouse.duckdb"
webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "")
if not webhook_url:
print("SLACK_WEBHOOK_URL not set. Skipping alert.")
raise SystemExit(0)
conn = duckdb.connect(str(db_path), read_only=True)
# Replace marts.fact_orders with your own model or table
result = conn.sql("""
SELECT MAX(order_date) AS last_order_date
FROM marts.fact_orders
HAVING MAX(order_date) < CURRENT_DATE - INTERVAL '1' DAY
""").fetchall()
conn.close()
if not result or result[0][0] is None:
print("Data is fresh — no alert needed.")
else:
last_date = result[0][0]
days_ago = (datetime.now(timezone.utc).date() - last_date).days
text = (
f":warning: *Stale Data Alert*\n"
f"Latest order date is {last_date} ({days_ago} days ago)."
)
payload = json.dumps({"text": text}).encode()
req = Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
)
urlopen(req)
print(f"Alert sent: data is {days_ago} days stale.")
Example 3: Google Sheets Export¶
This script queries DuckDB and exports the results to a Google Sheet.
Dependencies (scripts/requirements.txt):
Script (scripts/sheets_export.py):
"""scripts/sheets_export.py
Export a query result to Google Sheets.
"""
import os
from pathlib import Path
import duckdb
import gspread
project_root = Path(os.environ["DANGO_PROJECT_ROOT"])
db_path = project_root / "data" / "warehouse.duckdb"
conn = duckdb.connect(str(db_path), read_only=True)
df = conn.sql("""
SELECT order_date,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM raw.orders
GROUP BY order_date
ORDER BY order_date DESC
LIMIT 100
""").fetchdf()
conn.close()
gc = gspread.service_account()
sh = gc.open(os.environ.get("SHEET_NAME", "Dango Export"))
worksheet = sh.sheet1
worksheet.clear()
worksheet.update([df.columns.values.tolist()] + df.values.tolist())
print(f"Exported {len(df)} rows to sheet '{sh.title}'.")
Scheduling¶
You can attach a script to a cron schedule, just like sync and dbt schedules.
Select "Script (custom Python)" when prompted for the schedule type. Then choose a script from the list of discovered files. The wizard prompts for name, frequency, timezone, and optional notifications.
Script Schedule Fields¶
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Unique identifier (lowercase + underscores). |
type | string | yes | — | Must be script. |
cron | string | yes | — | 5-field cron expression or preset name. |
script_path | string | yes | — | Relative path inside scripts/. |
enabled | bool | no | true | Set to false to pause. |
timeout_minutes | int | no | 30 | Maximum execution time in minutes. |
timezone | string | no | UTC | IANA timezone (e.g., America/New_York). |
start_date | datetime | no | — | Don't run before this date. |
Timeout Defaults
Scheduled scripts default to 30 minutes. Manual "Run Now" executions default to 5 minutes. Override via timeout_minutes in the schedule config.
Path Validation¶
At schedule activation, Dango validates the script path:
- Path traversal check — the resolved path must stay inside
scripts/ - File existence check — the
.pyfile must exist - Syntax check — the file must parse with
ast.parse()(catches import errors at activation time rather than at runtime)
If validation fails, the schedule is skipped with a warning logged to .dango/logs/activity.jsonl.
Logs¶
Log Storage¶
Each script run creates a timestamped directory (or run UUID for manual runs):
.dango/logs/scripts/
├── my_report_20260713T070000/
│ ├── stdout.txt # Captured stdout (truncated at 1 MB)
│ ├── stderr.txt # Captured stderr (truncated at 1 MB)
│ └── meta.json # Run metadata (status, duration, exit code)
└── ...
Viewing Logs¶
Web UI: Click the script name or status badge in the Scripts table.
Filesystem:
Troubleshooting¶
Script not appearing in the Scripts tab¶
- Verify the file is in the
scripts/directory (project root, not inside.dango/) - Check the file name doesn't start with
_or.— these are hidden - Ensure the file has a
.pyextension - Restart or refresh the browser page
Path traversal error¶
Dango rejects paths that resolve outside the scripts/ directory. Use only relative paths within scripts/:
Script times out¶
- Manual runs timeout after 5 minutes. Long-running scripts should be scheduled instead.
- Scheduled runs timeout after 30 minutes. Increase via
timeout_minutesin the schedule config.
Requirements install failure¶
Check .dango/logs/activity.jsonl for the error. Install manually: pip install -r scripts/requirements.txt. Verify package names and versions.
Script succeeds but result is missing¶
- Check
stderr.txt— exceptions may have been printed there - If writing to DuckDB, verify the write lock was acquired
- Check the connection is in read-write mode (not
read_only=True)
Cancel doesn't stop the script¶
Dango sends SIGTERM first, waits 5 seconds, then SIGKILL. If the script traps SIGTERM, only SIGKILL terminates it.
Further Reading¶
- Scheduled Syncs — schedule types, cron expressions, timezone handling
- Webhook Notifications — get notified when scripts succeed or fail
- CLI Schedule Commands — full CLI reference for schedule management