| name | hamilton-dev-workflow |
| description | Systematic 5-step workflow for building Hamilton DAGs - DOT graphs, signatures, validation, TDD implementation. Use this workflow when creating new Hamilton modules from scratch. |
| allowed-tools | Read, Grep, Glob, Bash(python:*), Bash(hamilton:*), Bash(pytest:*) |
| user-invocable | true |
| disable-model-invocation | false |
Hamilton Development Workflow for Claude Code
TL;DR: Build Hamilton DAGs systematically using DOT → Signatures → Validation → TDD implementation.
Why This Workflow?
This workflow optimizes for:
- Token efficiency: DOT language is extremely concise
- Early validation: Catch graph errors before implementation
- LLM-friendly: Well-structured, incremental approach
- Testability: Build complex systems node-by-node with tests
- Pragmatic typing: Handle type annotations strategically
The 5-Step Workflow
Step 1: Natural Language → DOT Graph
Convert user requirements into DOT language representation
DOT language is:
- Simple and well-understood by LLMs
- Extremely token-efficient
- Perfect for representing DAG structure
- Easy to validate visually
Example:
User: "Load CSV data, clean nulls, calculate 7-day rolling average, output top 10"
DOT representation:
digraph hamilton_dag {
// Inputs
data_path [shape=box, color=blue]
// Pipeline (arrows show data flow: upstream -> downstream)
data_path -> raw_data
raw_data -> cleaned_data
cleaned_data -> rolling_7d
rolling_7d -> top_10
}
Best Practices:
- Use descriptive node names (snake_case)
- Mark inputs with
[shape=box, color=blue]
- Add comments for clarity
- Keep it simple - just the graph structure
Step 2: DOT Graph → Function Signatures + Docstrings
Convert DOT nodes into Python function signatures
This step is mechanical and can be automated. For each node:
- Create a function with the node name
- Add parameters based on incoming edges
- Add return type annotation (use
Any if uncertain)
- Write a clear docstring explaining what the node computes
Example:
"""Data processing pipeline."""
from typing import Any
import pandas as pd
def raw_data(data_path: str) -> pd.DataFrame:
"""Load raw data from CSV file.
:param data_path: Path to CSV file
:return: Raw DataFrame
"""
pass
def cleaned_data(raw_data: pd.DataFrame) -> pd.DataFrame:
"""Remove null values and duplicates.
:param raw_data: Raw data from previous step
:return: Cleaned DataFrame
"""
pass
def rolling_7d(cleaned_data: pd.DataFrame) -> pd.Series:
"""Calculate 7-day rolling average of sales.
:param cleaned_data: Cleaned data
:return: 7-day rolling average
"""
pass
def top_10(rolling_7d: pd.Series) -> pd.Series:
"""Get top 10 days by value.
:param rolling_7d: Rolling average values
:return: Top 10 values
"""
pass
Type Annotation Strategy:
- Known types: Use specific types (pd.DataFrame, pd.Series, int, str)
- Uncertain: Use
Any - you'll fix this later with MonkeyType
- Complex types: Start with
Any, refine incrementally
Step 3: Validate DAG with Hamilton CLI
Verify the graph structure before implementing logic
Use the Hamilton CLI to validate:
hamilton build --module my_pipeline
hamilton build --module my_pipeline --output dag.png
hamilton build --module my_pipeline --list-nodes
What to check:
- ✅ No circular dependencies
- ✅ All edges connect properly
- ✅ Input nodes are identified correctly
- ✅ Output nodes are what you expect
- ✅ No typos in function/parameter names
If validation fails:
- Fix the function signatures (parameter names, function names)
- Re-run
hamilton build
- Don't proceed to implementation until validation passes
Step 4: Function Signatures → TDD Implementation
Implement nodes incrementally with test-driven development
This is where the workflow shines for LLMs. Instead of writing all code at once:
Process:
- Pick ONE node to implement (start with inputs, work forward)
- Write a test for that node first
- Implement the node to pass the test
- Run the test to verify
- Move to the next node
Example - Node by node:
def test_raw_data(tmp_path):
"""Test CSV loading."""
csv_file = tmp_path / "data.csv"
csv_file.write_text("col_a,col_b\n1,2\n3,4")
result = raw_data(str(csv_file))
assert isinstance(result, pd.DataFrame)
assert len(result) == 2
assert list(result.columns) == ['col_a', 'col_b']
def raw_data(data_path: str) -> pd.DataFrame:
"""Load raw data from CSV file."""
return pd.read_csv(data_path)
def test_cleaned_data():
"""Test data cleaning."""
raw = pd.DataFrame({
'col_a': [1, 2, None, 4],
'col_b': [1, 1, 2, 2]
})
result = cleaned_data(raw)
assert len(result) == 3
assert result['col_a'].isna().sum() == 0
def cleaned_data(raw_data: pd.DataFrame) -> pd.DataFrame:
"""Remove null values and duplicates."""
return raw_data.dropna().drop_duplicates()
Why this works:
- Focused context: LLM only thinks about one node at a time
- Immediate validation: Each node is tested before moving on
- Easy refactoring: Well-tested nodes can be safely refactored
- Handles complexity: Build complex pipelines without "spaghetti code"
- Clear progress: Know exactly what's done and what's left
Step 5: Handle Type Annotations Pragmatically
Use MonkeyType or runtime inference to add accurate types
Don't spend time guessing types upfront. Instead:
Option A: Use Any everywhere initially
from typing import Any
def my_function(input_data: Any) -> Any:
"""Process data."""
pass
Option B: Disable type validation in Driver
from hamilton import driver
from hamilton.lifecycle import base
class NoTypeCheckAdapter(base.BaseDo):
"""Adapter that skips type validation."""
pass
dr = driver.Driver(
{},
module,
adapter=NoTypeCheckAdapter()
)
Option C: Run MonkeyType to infer types
After implementing and testing:
monkeytype run -m pytest tests/
monkeytype stub my_pipeline
monkeytype apply my_pipeline
MonkeyType will:
- Observe actual values passed at runtime
- Infer concrete types from observations
- Generate accurate type annotations
- Update your source files
Result: Accurate types without upfront guessing!
Important: MonkeyType isn't perfect, but it's:
- ✅ Much faster than manual annotation
- ✅ Based on real runtime behavior
- ✅ Good enough for first drafts
- ✅ Easy to refine manually later
Complete Example
User Request:
"Create a pipeline that loads JSON data, filters by status='active', enriches with external API data, and outputs to Parquet"
Step 1: DOT Graph
digraph pipeline {
// Inputs
json_path [shape=box, color=blue]
api_endpoint [shape=box, color=blue]
output_path [shape=box, color=blue]
// Pipeline (arrows show data flow: upstream -> downstream)
json_path -> raw_data
raw_data -> active_records
active_records -> enriched_data
api_endpoint -> enriched_data
enriched_data -> final_output
output_path -> final_output
}
Step 2: Function Signatures
"""Data enrichment pipeline."""
from typing import Any
import pandas as pd
def raw_data(json_path: str) -> pd.DataFrame:
"""Load data from JSON file.
:param json_path: Path to JSON file
:return: Raw DataFrame
"""
pass
def active_records(raw_data: pd.DataFrame) -> pd.DataFrame:
"""Filter for active status only.
:param raw_data: Raw data
:return: Filtered DataFrame
"""
pass
def enriched_data(active_records: pd.DataFrame, api_endpoint: str) -> pd.DataFrame:
"""Enrich with external API data.
:param active_records: Active records
:param api_endpoint: API endpoint URL
:return: Enriched DataFrame
"""
pass
def final_output(enriched_data: pd.DataFrame, output_path: str) -> str:
"""Save to Parquet file.
:param enriched_data: Enriched data
:param output_path: Output file path
:return: Path to saved file
"""
pass
Step 3: Validate
hamilton build --module enrichment_pipeline
Step 4: TDD Implementation
Test 1:
def test_raw_data(tmp_path):
json_file = tmp_path / "data.json"
json_file.write_text('[{"id": 1, "status": "active"}]')
result = raw_data(str(json_file))
assert len(result) == 1
Implementation 1:
def raw_data(json_path: str) -> pd.DataFrame:
return pd.read_json(json_path)
Test 2:
def test_active_records():
raw = pd.DataFrame([
{"id": 1, "status": "active"},
{"id": 2, "status": "inactive"}
])
result = active_records(raw)
assert len(result) == 1
assert result.iloc[0]["status"] == "active"
Implementation 2:
def active_records(raw_data: pd.DataFrame) -> pd.DataFrame:
return raw_data[raw_data["status"] == "active"]
...continue for each node...
Step 5: Add Types with MonkeyType
monkeytype run -m pytest tests/
monkeytype apply enrichment_pipeline
Result: Accurate type annotations added automatically!
Key Benefits of This Workflow
For LLMs (Claude):
- Structured thinking: Each step has clear boundaries
- Token efficient: DOT is concise, avoids redundant code
- Incremental: Build one piece at a time
- Validatable: Check work at each step
- Manageable complexity: Never overwhelmed with full implementation
For Developers:
- Visual validation: See the DAG before coding
- Test coverage: Every node is tested
- Maintainable: Well-structured, documented code
- Debuggable: Issues isolated to specific nodes
- Refactorable: Safe to modify with test coverage
Advanced Patterns
Using Function Modifiers
Add decorators in Step 2:
from hamilton.function_modifiers import parameterize
@parameterize(
rolling_7d={'window': 7},
rolling_30d={'window': 30},
rolling_90d={'window': 90},
)
def rolling_average(data: pd.Series, window: int) -> pd.Series:
"""Calculate rolling average.
:param data: Input data
:param window: Rolling window size
:return: Rolling average
"""
pass
Update DOT graph to reflect expanded nodes:
data -> rolling_7d
data -> rolling_30d
data -> rolling_90d
Async Functions
Mark async in Step 2:
async def api_data(endpoint: str, record_id: int) -> dict:
"""Fetch data from API.
:param endpoint: API endpoint
:param record_id: Record ID to fetch
:return: API response data
"""
pass
Use async driver:
from hamilton import async_driver
dr = await async_driver.Builder().with_modules(module).build()
Common Pitfalls
❌ Implementing before validating
Don't write function bodies before running hamilton build. Validate structure first!
❌ Writing all code at once
Don't implement all nodes before testing. Go node-by-node with TDD.
❌ Guessing type annotations
Don't waste time guessing types. Use Any + MonkeyType or disable validation initially.
❌ Skipping the DOT step
Don't jump straight to code. The DOT graph is your blueprint - validate it first.
❌ Not running tests
Don't implement the next node until the current one passes its test.
Quick Reference
hamilton build --module my_module
hamilton build --module my_module --output dag.png
pytest tests/test_my_module.py::test_my_function -v
monkeytype run -m pytest tests/
monkeytype apply my_module
python -c "from hamilton import driver; import my_module; driver.Driver({}, my_module)"
Summary
- DOT first: Design the graph in DOT language (token-efficient, validatable)
- Signatures next: Convert to function signatures with docstrings
- Validate early: Use
hamilton build before implementing
- TDD everything: Implement one node at a time with tests
- Types pragmatically: Use Any + MonkeyType or disable validation initially
This workflow enables Claude to build complex Hamilton DAGs systematically, avoiding the pitfalls of monolithic implementation.
For additional patterns and examples, see the skill-specific guides:
/hamilton-core - Core patterns and decorators
/hamilton-scale - Async, Ray, Dask, Spark
/hamilton-llm - LLM and RAG workflows
/hamilton-observability - Monitoring and tracking
/hamilton-integrations - Airflow, FastAPI, Streamlit