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()?
A value read from a CSV starts life as text. "4000" is the characters 4-0-0-0, not the
number 4000, so "4000" > 10000 would compare text, not numbers. .to_int() converts the
text to a whole number first, so the comparison means what you expect. (There’s also
.to_float(), .to_string(), and lenient try_int() that yields nothing instead of an
error on bad input.)
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`?
CSV fields arrive as text. Comparing text to a number wouldn't mean what you want, so you convert with .to_int() first. (Bad input would raise an error, or use try_int() to skip it.)
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.)
Not every mistake surfaces this early, though. --dry-run validates the config; it does
not trace your data flow, so a logic error like pointing a node’s input: at a misspelled
name isn’t caught here. That one only shows up when you actually run the pipeline.
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"# channels/acme-corp/customer_etl.channel.yaml — Acme wants a higher barchannel: name: acme-corp target: ./customer_etl.yamlvars: static: gold_threshold: { type: int, default: 50000 }Run it with clinker run customer_etl.yaml --channel channels/acme-corp/customer_etl.channel.yaml,
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.