SQL traits
A SQL trait is a computed trait authored as warehouse SQL instead of the JSON predicate DSL. You store one read-onlySELECT that returns (user_id, trait_value) rows against your own BigQuery, Snowflake, or Redshift; Orbit runs it on the schedule you set and stamps each row’s value into the matching contact’s traits map under your trait name. The value then behaves like any other attribute: segmentable, personalizable, exportable.
This surface is for SQL-forward and dbt-forward teams — the same people who already model everything else in the warehouse. If that is not you, the computed traits DSL covers the same outcome without SQL.
1. Choose between the four trait engines
Orbit now carries four ways to derive a trait. Pick deliberately:
A concrete decision tree:
- Is the data you need already in Orbit (contact facts, custom fields)? Author it as a DSL predicate — cheapest to run, easiest to preview.
- Is the source a warehouse table your analytics team already maintains? Author it as a SQL trait — Orbit reuses the model where it lives instead of a hand-ported copy.
- Does the trait need model judgment, not data? Use an LLM surface (AI traits or the autopilot).
2. Prerequisites
- A reachable warehouse. The trait runs against BigQuery, Snowflake, or Redshift through the same shared read path the reverse ETL warehouse exports use. You supply the connection credential per definition (section 4), encrypted at rest.
- An operator role. Creating, updating, and deleting definitions requires owner, admin, or developer. Reads accept the broader analyst and marketer set.
- A scalar query. Each definition returns exactly two columns:
user_id(the identity the value stitches to — it matches the contact’s external id) andtrait_value(the value written intotraits[<name>]). One row per profile.
3. Author the SELECT
The body must be a single read-only statement. The API rejects the rest at the edge, before anything is stored:Example — BigQuery, an LTV tier
Bucket customers into a tier from a purchases view your analytics team already owns:Example — Redshift, computed read-only
The same shape over the Postgres wire. The executor runs Redshift reads inside aREAD ONLY transaction, so a side-effecting body cannot execute even if it were stored:
4. Create a definition
POST the body, a name, the warehouse kind, and the connection credential. The name becomes thetraits[<name>] key, so it must be a lowercase identifier (starts with a letter; letters, digits, underscores after). Duplicate names come back 409.
name(required) — the trait key. Stamped astraits.ltv_tieron each matched contact.warehouse(required) —bigquery,snowflake, orredshift.sql(required) — the read-only body, up to 20,000 characters.connection(required) — a JSON credential blob, encrypted at rest the moment it lands; no read path ever returns it (see section 7 for rotation).schedule_minutes(optional, default 1440) — how often the runner re-evaluates. Set the daily default unless the trait genuinely needs a tighter loop.enabled(optional, default true) — a disabled definition persists but no longer evaluates; values it already stamped stay in place.
INVALID_SQL means the body failed the read-only guard — the rejection reason names the exact keyword or missing column. A 400 INVALID_CONNECTION means the credential blob is not a JSON object.
5. Prevalidate before you persist
Rule-builder previews and your own editor both go through the validate endpoint. It applies the same guard as create, writes nothing, and costs nothing:{ "valid": true } or { "valid": false, "reason": "…" }. Iterate here until the body passes; then POST it with confidence. Validation is deliberately conservative — it is an edge guard, and the executor is the authoritative boundary (read-only warehouse role, statement timeout) — so a body that passes validation can still fail at run time (a missing table, a revoked grant). Watch the run-status fields after the first cycle.
6. Materialization cadence
Nothing computes at create time. A scheduled runner sweeps every enabled definition on a 15-minute outer tick and evaluates the ones that are due — never run yet, or whose last run is at leastschedule_minutes old. For each due definition it:
- Re-reads the definition and re-validates it (a hand-edited body that fails validation is marked failed, never executed).
- Runs your
SELECTagainst the warehouse over a read-only connection, capped at 50,000 rows per run. - Stitches each row to a contact:
user_idis matched against the contact’s external id, and last row wins when auser_idappears more than once. - Writes
trait_valueintotraits[<name>]with a JSON merge — every sibling trait already on the contact is preserved. Only existing, non-deleted contacts are updated; the runner never creates orphan contacts. - Records the outcome on the definition —
last_run_at,last_run_status,last_run_rows, and a sanitizedlast_erroron failure. Those fields are operator-read-only: an operator cannot forge a green run to mask a broken query.
7. Rotate the credential per definition
Each definition carries its ownconnection blob. To rotate, PATCH the definition with the fresh credential — a dedicated, SELECT-only warehouse identity per definition limits blast radius:
- BigQuery — a service-account JSON scoped to
bigquery.readonly. - Snowflake — key-pair auth:
{ "account": "…", "username": "…", "private_key": "…" }for a dedicated user with SELECT only. - Redshift — Postgres-wire:
{ "host": "…", "database": "…", "username": "…", "password": "…", "port": 5439 }for a login restricted to SELECT.
8. Production checklist
Before you leave a SQL trait running unattended:- The query is read-only in spirit, not just keyword-clean. Prefer a dedicated reporting view or dbt model over raw operational tables — it keeps the trait stable when schema underneath shifts.
user_idis the right identity. It must match the value the contact carries as its external id; join through your identity mapping in the warehouse if the raw table carries a different key.- The credential identity is SELECT-only and scoped to exactly the tables/view the query touches.
- Validate first, then create. Run the body through
POST /cdp/sql-traits/validate, then persist it. - Check the first run. After one cycle, GET the definition and confirm
last_run_status: "ok"and a plausiblelast_run_rows. A failed run’slast_erroris sanitized but specific enough to act on (missing table, revoked grant, timeout). - Size the schedule. The default daily loop fits most traits; set
schedule_minuteslower only when downstream journeys genuinely act on the fresher value. - Segment on the stamped value.
traits.ltv_tierequalsgoldbehaves exactly like a DSL-stamped tag — filter on it in segments, branch on it in journeys, export it with the profile.