Transforms & CXL — compute new fields
The question for this lesson: your source gives you the columns that were in the file. But you almost always need new columns: a flag, a category, a cleaned-up value. How do you compute a new field for every record?
What you’ll be able to do
Section titled “What you’ll be able to do”- Read and write a
transformnode that adds fields with CXL. - Use
emit, comparisons,if/then/else, and a method like.to_int(). - Pull a value out of a channel with
$vars, so one pipeline serves many deployments. - Validate a pipeline’s config and declared types with
--dry-runbefore you run it.
This lesson assumes you’ve met the pipeline mental model and the source/transform/output nodes from What a pipeline is. If “record” or “node” feels fuzzy, start there.
Predict first
Section titled “Predict first”What CXL is
Section titled “What CXL is”CXL
is the little language inside a transform’s cxl: block. You write one statement per
line. The ones you’ll use constantly:
| Statement | What it does | Example |
|---|---|---|
emit <name> = <expr> | add or overwrite a column | emit total = price * qty |
let <name> = <expr> | a temporary value (not a column) | let net = price - discount |
filter <condition> | drop rows where the condition is false | filter status == "active" |
distinct | keep only unique rows | distinct by id |
Expressions use the operators you’d expect (+ - * /, == != > <, and or not), plus
if <cond> then <a> else <b>, and methods on values like .to_int(), .upper(),
.trim().
🌱 New here? — why .to_int()?
This example deliberately declares lifetime_value as string, so the transform
converts it before comparing it with a number. CSV does not force that choice:
with type: int in the source schema, write lifetime_value > 10000 directly.
Comparing a string with an integer without conversion is a type error, not a
lexicographic numeric comparison. try_int() returns null on invalid text; it
does not drop the row unless you explicitly filter that result.
Use: a real transform
Section titled “Use: a real transform”This is the canonical customer_etl pipeline. The second transform tags each customer by
lifetime value. Predict the output, then run it.
> output appears here — predict, then run
// quick check
Why is it `lifetime_value.to_int() > 10000` and not just `lifetime_value > 10000`?
The declared string needs an explicit conversion. A source column declared int would already be numeric. Bad text makes to_int() fail; try_int() returns null, which you can handle or filter explicitly.
Modify: add a field
Section titled “Modify: add a field”Add a third column. After the final_flag transform computes tier, add one more
emit line so the output also includes a greeting column equal to
"Hello " + status.
Validate: catch a config mistake with --dry-run
Section titled “Validate: catch a config mistake with --dry-run”Before you run a pipeline over real data, --dry-run reads the config and checks its
structure and declared types, without processing a single record. This pipeline has a
mistake of that kind: the schema declares lifetime_value as dollars, which is not a
real type. Predict what --dry-run does, then run it.
> output appears here — predict, then run
--dry-run catches config and type mistakes before any data is read: a missing required
field, or, as here, a schema type that doesn’t exist. The error names the valid types and
the run stops with exit 1, so you fix it in a second instead of after a long job. (Fix
it by declaring lifetime_value as string, the way the working pipeline above does.)
Planning-only --dry-run also rejects a misspelled input:, unknown CXL
field/method, or incompatible type. It cannot validate file contents without
reading records; use a bounded preview or an isolated run for that.
Create: one pipeline, many deployments
Section titled “Create: one pipeline, many deployments”Hard-coding 10000 means every team gets the same threshold. Instead, read it from a
channel
variable with $vars. The pipeline declares the variable; a channel file overrides it per
deployment.
# customer_etl.yaml — declare the variable, then read itpipeline: name: customer_etl vars: gold_threshold: { type: int, default: 10000 }nodes: # ... source as before ... - type: transform name: final_flag input: customers config: cxl: | emit tier = if lifetime_value.to_int() > $vars.gold_threshold then "gold" else "standard"# channel/acme-corp/customer_etl.channel.yaml — Acme wants a higher barchannel: target: ../../customer_etl.yamlvars: static: gold_threshold: { type: int, default: 50000 }After creating the workspace catalog and channel manifest as shown in Channels, run it with clinker run customer_etl.yaml --channel acme-corp --base-dir .,
and Acme’s customers need 50000 to reach “gold”: same pipeline, no logic changed.
// quick check
What does a channel let you change about a pipeline?
A channel overlays variables and config for one deployment. The pipeline's logic stays put, and that's what makes the same pipeline safe to reuse across teams.