When to Use
- Writing a new
@onlinePython resolver - Choosing between a Python resolver and a feature expression (
F.http_post,_.foo, SQL file) - Picking the right input/output shape: scalar,
Features[...],DataFrame[...], has-many - Pulling fields from related namespaces via dotted feature paths
- Adding
Nowfor "as-of" / point-in-time arithmetic (optional; mostly for time-dependent features like age calculations) - Parsing protobuf / JSON / vendor payloads into many features at once
- Aggregating a has-many
DataFramedown to a scalar (risk score, totals, ratios) - Tagging resolvers, scoping to an environment, or routing to a specific machine type
- Writing unit + integration tests for resolvers
Minimal Example
from datetime import timezone
from chalk import online, Now
from src.models import Account
@online
def calculate_account_age(
signed_up_at: Account.signup.signed_up_at, # joins Account → Signup automatically
now: Now, # query "as-of" time; defaults to wall clock
) -> Account.account_age_days:
if signed_up_at is None:
return None
if signed_up_at.tzinfo is None:
signed_up_at = signed_up_at.replace(tzinfo=timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return (now - signed_up_at).days
This single resolver supports both real-time and historical queries — pass now=... to ChalkClient.query and Chalk replays it for that point in time.
When NOT to Use
- The value already lives in a system you can call declaratively — use a feature expression instead:
- HTTP call →
F.http_post(...)on the feature (see Feature Expressions vs Python Resolvers) - SQL row → drop a
.chalk.sqlfile insrc/resolvers/sql/ - Simple arithmetic across columns →
_.a + _.bon the feature
- HTTP call →
- Pure DataFrame transformation at
chalk applytime — use@online(static=True)with chalkdf instead. Seewriting-static-chalkdfskill. - Streaming source (Kafka/Kinesis/PubSub) — use
make_stream_resolver. Seechalk-streamingskill. - Batch backfills from a warehouse — use
@offlineresolvers orchalk upload.
Resolver Signature Shapes
The @online decorator works on any function whose parameters and return are typed with Chalk feature references. The four shapes you'll actually use:
1. Scalar in → scalar out
@online
def normalize_score(raw: User.raw_score) -> User.normalized_score:
return min(max(raw, 0.0), 1.0)
Each input is a Class.field. Chalk resolves the value for that entity and passes it as the typed argument.
1b. No-argument resolver (constants / env lookups)
@online
def get_platform_version() -> Config.platform_version:
return os.getenv("PLATFORM_VERSION", "2.1.0")
Use when the value is a constant, comes from environment, or is derived from process-local state. Called once per query — not cached across requests; if you want caching, set max_staleness on the feature.
2. Cross-namespace input via dotted path
@online
def derive_thing(
signed_up_at: Account.signup.signed_up_at, # Account → Signup join
bi_score: Account.business_intelligence.score, # Account → BI join
) -> Account.thing: ...
Chalk traverses the relationship at query time. Don't manually fetch the parent — declare the dotted path and Chalk resolves the join. Works for has_one and (when you select a single sub-feature) for any relation.
3. Features[...] — many features out in one shot
from chalk import online, Features
@online
def parse_signup_response(
body: Signup.signup_response_body,
) -> Features[
Signup.signed_up_at,
Signup.first_name,
Signup.last_name,
Signup.company_name,
# ... 20 more
]:
response = signup_pb2.GetSignupResponse()
response.ParseFromString(body)
info = response.information
return Signup(
signed_up_at=response.signed_up_at.ToDatetime() if response.HasField("signed_up_at") else None,
first_name=info.first_name if info else None,
last_name=info.last_name if info else None,
company_name=info.company_name if info else None,
# ...
)
Use this for proto/JSON unpacking, vendor-payload parsing, or anywhere one input produces a fan-out of scalar features on the same entity. Return the feature class constructed with kwargs — Chalk extracts the fields listed in Features[...].
4. DataFrame[...] in → scalar out (has-many aggregation)
from chalk.features import DataFrame
from datetime import datetime
def _most_recent_score(profiles: DataFrame) -> int | None:
try:
rows = list(profiles)
if not rows:
return None
most_recent = max(rows, key=lambda r: r.inserted_at or datetime.min)
return most_recent.primary_score
except Exception:
return None
@online
def calculate_risk_score(
credit_profiles_df: Account.credit_profiles, # has-many DataFrame
total_recent_txns_30d: Account.total_recent_txns_30d,
age_days: Account.account_age_days,
) -> Account.risk_score:
score = _most_recent_score(credit_profiles_df)
# ... combine with other factors, return float
When you reference a has-many relation (Account.credit_profiles) as a parameter, Chalk passes you the related rows as a DataFrame. Iterate with list(df) and access fields by name (row.inserted_at), or use chalkdf methods. Don't call .run(), .to_pandas(), or anything that tries to materialize — at query time you're already holding real data; at plan time (static resolvers) you'd crash.
5. Has-many out — construct a DataFrame[Child]
@online
def parse_account_individuals(
body: Account.account_response_body,
account_id: Account.customer_account_id,
) -> Account.individuals[ # selects which Individual fields you produce
Individual.email,
Individual.full_name,
Individual.date_of_birth,
]:
response = kyc_pb2.GetAccountResponse()
response.ParseFromString(body)
return Account(
individuals=DataFrame([
Individual(
email=ind.email_address,
customer_account_id=account_id,
full_name=ind.full_name,
date_of_birth=datetime(
year=ind.date_of_birth.year,
month=ind.date_of_birth.month,
day=ind.date_of_birth.day,
),
)
for ind in response.individuals
])
)
The return annotation Account.individuals[Individual.field1, ...] declares which child fields this resolver produces. The body returns the parent class with individuals=DataFrame([Child(...), ...]).
The Now Magic Input
from chalk import Now
@online
def f(ts: Account.signed_up_at, now: Now) -> Account.age_days: ...
- For real-time queries:
now= current wall-clock time - For point-in-time queries (
client.query(now=datetime(2024,1,1))):now= the requested instant - For offline / backtest queries:
now= the row's timestamp
You write the resolver once and get correct behavior in all three. Always make timestamp inputs timezone-aware before subtracting (tz=timezone.utc) — wall-clock times often arrive naive.
Feature Expressions vs Python Resolvers
Many resolvers customers write as Python should actually live on the feature class as an expression. Rule of thumb: if it's declarative (call an HTTP endpoint, pluck a JSON path, do arithmetic on two columns), use an expression. If it's procedural (branching, iteration, library calls, parsing), use @online.
Pattern: HTTP body as a feature, Python resolver to parse it
# models.py — declarative: Chalk makes the call, retries, caches
@features
class Signup:
customer_account_id: Primary[str]
signup_request: bytes
signup_response_raw: HttpResponse[bytes] = F.http_post(
get_url("/server.v1.SignupService/GetSignup"),
headers=get_headers(),
body=_.signup_request,
timeout=timedelta(seconds=5),
)
signup_response_body: bytes = _.signup_response_raw.body
# ... scalar fields populated by the parser below ...
# resolvers/proto_parsers.py — procedural: parse bytes → many features
@online
def parse_signup_response(body: Signup.signup_response_body) -> Features[...]:
response = signup_pb2.GetSignupResponse()
response.ParseFromString(body)
return Signup(...)
Why split it: the expression form gets you connection pooling, retries, observability, and timeouts for free. The Python resolver only handles what genuinely needs Python (proto deserialization).
Useful F. functions worth knowing before reaching for Python
| Need | Use |
|---|---|
| HTTP call | F.http_post(url, headers=..., body=..., timeout=...), F.http_get(...) |
| JSON path extraction | F.json_value(_.payload, "$.user.email") |
| Unix timestamp → datetime | F.from_unix_seconds(_.ts), F.from_unix_milliseconds(_.ts_ms) |
| Proto deserialize | F.proto_deserialize(_.bytes, MyProto) (use F.recover to swallow bad bytes) |
| Conditional | F.if_then_else(cond, if_true, if_false) |
| Coalesce | F.coalesce(_.a, _.b, default) |
Reach for a Python @online when none of these compose to what you need.
Decorator Options
@online # bare — runs in default env, no tags, no machine hint
@online(tags=["pii"]) # gates by tag at query time
@online(environment="production") # only runs in this env
@online(machine_type="cpu-large") # route to a specific machine class
@online(resource_hint="cpu") # scheduling hint: "cpu", "io", or "gpu"
@online(resource_group="streaming") # pin to a named worker resource group
@online(static=True) # runs once at chalk apply, builds a chalkdf plan
@online(owner="team-fraud") # surfaces in dashboards, lineage, and alerts
@online(timeout="5s") # per-call timeout; resolver fails if exceeded
- Default to bare
@online. Add options only when there's a concrete reason. resource_hint:"gpu"for ML inference;"cpu"is common for compute-heavy aggregations on has-many DataFrames;"io"for I/O-bound work. The hint is advisory — actual routing happens in the worker dispatcher, not at plan time.static=Trueis a different beast — see thewriting-static-chalkdfskill.
Null Handling
Chalk represents missing data as None. Adopt these conventions and most footguns disappear:
- Type all nullable inputs/outputs
T | None. If a resolver can fail to produce a value, returnNoneinstead of raising. - Guard before arithmetic.
if signed_up_at is None: return Nonethen continue. - Iterate has-many DataFrames defensively:
try: rows = list(df) if not rows: return None ... except Exception: return None - Don't swallow errors silently in production-critical resolvers. Either return
Noneand log, or let it raise. A bareexcept: passhides real bugs. - Coalesce at the call site, not inside the resolver, when the caller has better context for the default:
txn_factor = _normalize_txn_factor(total_recent_txns_30d or 0)
Organizing Resolvers
Conventions that scale well in real codebases:
- Small projects (<50 resolvers): a single
resolvers.pyis fine. Larger codebases benefit from splitting by domain:account_resolvers.py,risk_resolvers.py,proto_parsers.py. Pick the cut that matches your team's mental model. - Helpers prefixed with
_and kept in the same file as their caller (or in ahelpers/subdir if shared across domains). - Module-level singletons for DB clients, HTTP sessions, and auth helpers — instantiated once at import time, reused across resolvers:
Don't construct clients inside the resolver body — you'll exhaust connection pools and lose retry/instrumentation benefits.# src/datasources.py — DB clients pg = PostgreSQLSource(name="financials_db") snowflake = SnowflakeSource(name="warehouse") # src/utils/grpc_requests.py — HTTP helpers used inside F.http_post(...) def get_url(path: str) -> str: ... def get_headers() -> dict[str, str]: ... - SQL resolvers live as
.chalk.sqlfiles insrc/resolvers/sql/and don't need a Python wrapper. Chalk discovers them automatically. - Imports at the top of every resolver file:
from chalk import online, Now, Features from chalk.features import DataFrame, _ from datetime import datetime, timedelta from src.models import ...
Logging
Import chalk_logger for resolver-side logs that flow into Chalk's observability:
from chalk.clogging import chalk_logger
@online
def parse_vendor_response(body: Vendor.response_body) -> Vendor.parsed_id:
try:
payload = vendor_pb2.Response.FromString(body)
return payload.id
except Exception as e:
chalk_logger.info(f"vendor parse failed: {e}")
return None
- Prefer
return None+ log overraisefor known-failure cases — keeps the rest of the feature graph computable. - Don't log raw PII or vendor payloads — log identifiers and error classes.
Request-scoped metadata via ChalkContext
Use ChalkContext.get(key) to read metadata the caller attached at query time via ChalkClient.query(context={...}). Useful for correlation IDs, tenant flags, A/B variants — anything that varies per request without changing the resolver signature:
from chalk import ChalkContext
@online
def get_pricing_tier(base: Account.base_tier) -> Account.pricing_tier:
if ChalkContext.get("experiment") == "premium-rollout":
return "premium"
return base
Testing Resolvers
Unit test — call the function directly
A Python resolver is a regular Python function. Test the logic without spinning up Chalk:
# tests/unit_test.py
from src.resolvers.risk_resolvers import derive_composite_score
def test_composite_score_balances_internal_and_external():
score = derive_composite_score(
owners_leadership_score=80,
operational_maturity_score=70,
customer_base_score=90,
business_model_score=60,
industry_macro_score=75,
)
assert score == 78.5
Fast feedback, no network, runs in a normal pytest loop. Cover edge cases (None inputs, empty DataFrames, boundary values).
Integration test — ChalkClient.check against a branch
For anything that depends on relationships, expressions, or aggregations, exercise the full feature graph against a deployed branch:
# tests/conftest.py
import os, pytest
from chalk.client import ChalkClient
@pytest.fixture(scope="session")
def chalk_client():
branch = os.environ.get("GITHUB_REF_NAME") or True # True → auto-branch from local commit
yield ChalkClient(branch=branch)
# tests/integration_test.py
from datetime import datetime, timezone
from src.models import Account
def test_risk_score(chalk_client):
chalk_client.check(
input={Account.customer_account_id: "acct_123"},
assertions={Account.risk_score: 42.0},
now=datetime(2024, 6, 1, tzinfo=timezone.utc),
show_table=True,
tags=["integration_testing"],
)
branch=Trueauto-creates a branch from your local commit so PR runs don't collide.now=...exercises the same code path that backtesting uses — a good way to catch tz-naive bugs.assertions={...}does an exact match; for floats, preferpytest.approx.
Wiring in CI
Run unit tests on every PR (no Chalk credentials needed). Run integration tests against a branch deployment with the integration_testing tag so production resolvers aren't accidentally invoked.
Common Mistakes
| Mistake | Fix |
|---|---|
Calling df.run() / df.to_pandas() inside a static resolver |
At plan time you're operating on a placeholder; use chalkdf operations and return a lazy frame |
Subtracting a naive datetime from Now |
Coerce tzinfo=timezone.utc on both sides before arithmetic |
Returning a dict / tuple from a Features[...] resolver |
Return the feature class constructed with kwargs: return Account(field=..., field=...) |
| Manually joining parent → child by querying inside the resolver | Use a dotted path input (Account.signup.signed_up_at) and let Chalk resolve the join |
Wrapping every F.http_post in a Python resolver |
Keep the HTTP call as an expression on the feature; only write Python to parse the response body |
@online decorator missing on the function |
Bare def f(...) is a helper, not a resolver — Chalk won't register it |
Catching Exception and returning a default silently |
Either return None and log, or let it raise; silent defaults hide schema drift |
One giant resolvers.py with 80 functions |
Split by domain (account_resolvers.py, risk_resolvers.py, proto_parsers.py) |
| Per-call gRPC client construction | Use module-level singletons or fold the call into an F.http_post expression |
Common Errors
| Error | Cause | Fix |
|---|---|---|
Resolver returned None but field is non-nullable |
Output type is int but resolver returns None on some path |
Make the output int | None, or branch so all paths return an int |
unable to deserialize Chalk DataFrame at chalk apply |
Static resolver returned an eagerly-materialized frame | Don't call .run()/.to_pandas(); return a lazy chalkdf — see writing-static-chalkdf skill |
LazyFramePlaceholder: unsupported type |
A static resolver used an op chalkdf can't serialize | Switch to a supported chalkdf op or move logic to a non-static @online |
Features[X, Y] resolver returned object missing attribute X |
Constructor missing a kwarg listed in Features[...] |
Pass every listed feature as a kwarg, even if it's None |
| Resolver runs in dev but not in prod | @online(environment="staging") or tag gate |
Check the decorator; remove the gate or add prod to the allow-list |
tzinfo comparison TypeError |
Naive vs aware datetime arithmetic | Coerce both sides to tzinfo=timezone.utc before subtracting |
Has-many DataFrame arg is None instead of empty |
Caller didn't request the relation; resolver wasn't given the data | Check the query's output set; ensure the relation field is requested or has a default |
ChalkClient.check assertion fails by tiny epsilon |
Exact float comparison | Use pytest.approx(expected, rel=1e-6) or assert within a tolerance |
Further reading
BASE_CHALK_PROMPT.md— core Chalk concepts (features, relationships, queries)writing-static-chalkdfskill — for@online(static=True)chalkdf resolverschalk-streamingskill — formake_stream_resolver(Kafka/Kinesis/PubSub)- https://docs.chalk.ai/docs/python-resolvers — canonical reference
- https://docs.chalk.ai/llms.txt — full docs optimized for AI assistants