Transformers — repair the data at the door
Legacy exports arrive dirty: broken accents,
31/12/2019dates,1 234,56 €amounts, a value written once for a whole block. Transformers repair them as the file is read. You declare them in the connector manifest; no code.
Every parameter, on one page: Transformers — reference.
Where it applies
- A
csv-fileconnector reads them in the manifest'sspec.mappingblock. - A REST, GraphQL or SOAP endpoint reads them in its own
mapping, over its promoted columns: Run hooks over the promoted columns. One value alone takestransform:. - Not
lm csv ingest. The one-shot load never transforms. Apply acsv-fileconnector. - A JDBC or Kafka connector writes what it reads. A JDBC table can only rename a column: JDBC: the hub table.
- Dirty status values (
fini,TERMINE) are not repaired here. Declare them as workflow aliases.
Try a manifest on a sample file first, read the result, then point it at the real feed.
lm validate checks the manifest, not your data.
How a mapping reads
spec:
mapping:
dataset: # 1 — the whole batch of rows
- skip-empty-rows: {}
row: # 2 — each row; may drop it or split it
- map-columns: { rename: { CUST_NM: name } }
cell: # 3 — each value, in place
- fix-mojibake: { columns: [name] }
- The three tiers always run in this order:
dataset, thenrow, thencell. - Inside a tier, hooks run top to bottom. The order is yours, and it matters.
- One entry per hook:
- hook-name: { parameters }. lm validaterefuses a misspelt hook or parameter, and names the entry:spec.mapping.cell[0].upper.colums→upper hook: unknown parameter 'colums' — accepted: columns.- It refuses a misspelt tier too:
spec.mapping.cel→mapping: unknown key 'cel' — accepted: dataset, row, cell, on-error.
Nothing is dropped silently. A row leaves by one of three doors:
| The row | Hook | Counted in |
|---|---|---|
| excluded by a declared filter | filter-where, skip-empty-rows |
SKIPPED |
| missing a mandatory field | validate-row |
SKIPPED and the dead letters |
| written, one value emptied | a cell hook that cannot read the value | cell_errors |
The file has the wrong shape
require-headers — refuse a file with the wrong shape
dataset:
- require-headers: { columns: [c_bpartner_id, name], any-of: [created, isactive] }
| The file's header | Result |
|---|---|
c_bpartner_id,name,created,isactive |
passes |
c_bpartner_id,name,created |
passes: every columns, one of any-of |
c_bpartner_id,name |
fails the run: none of required headers found: [created, isactive] |
name,created |
fails the run: required header missing: c_bpartner_id |
A headerless file is checked against the parser.columns you declared.
skip-empty-rows — blank lines in the export
dataset:
- skip-empty-rows: {}
| In | Out |
|---|---|
C1,Acme Corp |
kept |
, |
dropped, counted in SKIPPED |
C2,Globex |
kept |
filter-where — keep only the rows you asked for
dataset:
- filter-where: { column: status, in: [ACTIVE, PENDING] }
status |
in: [ACTIVE, PENDING] |
equals: ACTIVE |
regex: "[0-9]+" |
|---|---|---|---|
ACTIVE |
kept | kept | dropped |
PENDING |
kept | dropped | dropped |
1042 |
dropped | dropped | kept |
10a |
dropped | dropped | dropped: the pattern must match the whole value |
| (empty) | dropped | dropped | dropped |
- Several criteria combine: a row must match them all.
- A dropped row counts in
SKIPPED. It is not a dead letter.
Legacy column names
map-columns — legacy column names (CUST_NM)
row:
- map-columns:
rename: { CUST_NM: name, CUST_CITY: city }
drop: [internal_flag]
| In | Out |
|---|---|
CUST_NM,CUST_CITY,internal_flag |
name,city |
Bénédict Martin,Montréal,X |
Bénédict Martin,Montréal |
- It renames first, then drops:
dropuses the new names. -
Rename a column away from a name the hub keeps for itself (
id,tenant_id).lm validaterefuses the opposite:ERROR spec.mapping.row[0].map-columns.rename.CUST_ID targets 'id', a hub system column (_ingested_at, _mapping_version, _row_hash, _run_id, _source, id, tenant_id) — renaming a source column AWAY from one of these is what the rename is for, renaming into one only recreates the collision -
Renaming onto a column the file already has overwrites it, and counts one
cell_errorsper row.
coalesce — first non-blank of several columns
row:
- coalesce: { column: display_name, from: [trade_name, name] }
display_name |
trade_name |
name |
→ display_name |
|---|---|---|---|
| (absent) | Dupont |
DUPONT SARL |
Dupont |
| (absent) | (blank) | DUPONT SARL |
DUPONT SARL |
Kept |
Dupont |
DUPONT SARL |
Kept: a filled target is never touched |
It copies the value as it arrived, before the cell hooks change it.
defaults — fill blanks in several columns
row:
- defaults: { is_active: "Y", country: FR }
is_active |
→ |
|---|---|
N |
N: never overwritten |
| (blank) | Y |
| (absent) | Y: the column is created |
One column, later in the order: default.
Rows that must not land incomplete
validate-row — rows missing mandatory fields
row:
- validate-row: { required: [code, name] }
A file with C1,Acme Corp and C2, (no name):
$ lm run get fc9b4ba6
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
fc9b4ba6 csv-customers default Completed 2 1 1 1 2026-10-19 07:19 2026-10-19 07:19
⚠ 1 rows quarantined — see: lm dlq list --run fc9b4ba6-5b32-49a3-af64-f96b8c344a2c
C1lands.C2goes to the dead letters, with the reasonrequired column 'name' is missing or blank.- Place
validate-rowbeforesplit-row.lm validaterefuses the other order. - Fix the source, run again, then close the dead letter: Diagnosing a failed run.
An unreadable amount is a wrong row: on-error: dlq
By default, a value a hook cannot read is emptied, and the row lands. For an invoice, an amount
written but unreadable, such as n/a, is wrong. Say so for that column. The file:
invoice_no,customer,amount_eur
F-1001,ACME,1250.00
F-1002,Globex,n/a
F-1003,Initech,89.90
The manifest's mapping:
mapping:
on-error:
fields: { amount_eur: dlq } # this column: set the row aside
default: soft # every other column: write it, count the cell
cell:
- parse-number: { column: amount_eur, decimal-separator: "." }
The run sets F-1002 aside, as it was read:
$ lm run get 460efbba
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
460efbba invoices default Completed 3 2 1 1 2026-10-24 22:15 2026-10-24 22:15
⚠ 1 rows quarantined — see: lm dlq list --run 460efbba-458f-461d-b6d3-45e439415abe
$ lm dlq get 260a6940-0d56-4478-a743-a5b8713660d8
ID: 260a6940-0d56-4478-a743-a5b8713660d8
Connector: invoices (a7487f8c-c3db-4afa-adc5-88e2ab8cb487)
Run: 460efbba-458f-461d-b6d3-45e439415abe
Reason: MappingFailed
Attempts: 1
Message: amount_eur: parse-number could not read the value
Detail: {"field":"amount_eur","message":"parse-number could not read the value"}
Payload: {"customer":"Globex","amount_eur":"n/a","invoice_no":"F-1002"}
Resolution: open
- Three modes:
soft(the default) writes the row;dlqsets it aside;failstops the run. - For a
parse-*hook, a blank cell (empty, spaces, tabs) is not unreadable. It stays empty, is not counted, andon-errordoes not see it. - A row whose amount is missing is
validate-row's job: rows missing mandatory fields. fieldsnames thecolumn:of a hook. Full example:docs/connectors/csv/72-on-error-dlq.yaml.
Broken text
clean — BOM, stray whitespace, control characters
cell:
- clean: { columns: [name, note], strip: true, remove-control-chars: true, normalize-unicode: NFC }
| In | Out |
|---|---|
hello\t (a BOM, two spaces, a tab) |
hello |
Café written as e + a separate accent |
Café with one é (NFC) |
- A BOM at the start is always removed. The three switches are off by default.
- Use
NFCwhen two exports spell the same accent two ways and your keys stop matching.
fix-mojibake — accents came out as Bénédict
A Windows export often garbles accents. This hook repairs them, only when it recognizes the pattern:
cell:
- fix-mojibake: { columns: [name, city] }
| In | Out |
|---|---|
Bénédict Martin |
Bénédict Martin |
CAFÉ DU PORT, l’atelier, 10 € |
CAFÉ DU PORT, l’atelier, 10 € |
Bénédict Martin |
unchanged: a clean value is never touched |
Bénédict € |
unchanged, +1 cell_errors: repairing it would garble the € |
A value it declines lands as it arrived. Re-export it from the source with the right encoding.
decode-html-entities — & in text
| In | Out |
|---|---|
Eau & Soleil |
Eau & Soleil |
café |
café |
replace-smart-quotes — curly quotes from Word and Excel
| In | Out |
|---|---|
“Eau & Soleil” – depuis 1998… |
"Eau & Soleil" - depuis 1998... |
l’été |
l'été |
«bonjour» |
"bonjour" |
strip-invisible — a code that "looks equal" but never matches
| In | Out |
|---|---|
ABCD (an invisible space inside) |
ABCD |
softhyphen |
softhyphen |
remove-diacritics — ASCII-only identifiers
| In | Out |
|---|---|
Montréal |
Montreal |
upper / lower / titlecase — case
| Hook | In | Out |
|---|---|---|
upper |
sarl |
SARL |
lower |
Montreal |
montreal |
titlecase |
MARTIN DUPONT (two spaces) |
Martin Dupont |
They change text cells only. titlecase also collapses repeated spaces.
truncate — hard cut
- truncate: { column: name, length: 60 } keeps the first 60 characters.
All these hooks take columns: [a, b], or every column when columns is left out:
cell:
- decode-html-entities: { columns: [slogan] }
- replace-smart-quotes: { columns: [slogan] }
- strip-invisible: { columns: [code] }
- remove-diacritics: { columns: [city] }
- titlecase: { columns: [name] }
Dates, amounts and flags
parse-date — dates are 31/12/2019
cell:
- parse-date: { column: signup_date, formats: ["dd/MM/yyyy", "yyyy-MM-dd HH:mm:ss"] }
| In | Out |
|---|---|
31/12/2019 |
2019-12-31: a date |
2004-10-18 23:42:39 |
2004-10-18 23:42:39+00: a timestamp |
12/31/2019 |
(empty), +1 cell_errors: no format matched |
- Formats are tried in order.
ddis the day,MMthe month,yyyythe year. Lowercasemmmeans minutes: the classic trap.- A format with a time (
HH:mm) gives a timestamp. Without, a date.
Which time zone a time is in
An ERP writes 2026-08-02 23:30:00 without a time zone. Say which one it is:
cell:
- parse-date: { column: stamp, formats: ["yyyy-MM-dd HH:mm:ss"], zone: Europe/Paris }
| In | zone |
Stored (shown in UTC) |
|---|---|---|
2026-08-02 23:30:00 |
(none) | 2026-08-02 23:30:00+00: read as UTC |
2026-08-02 23:30:00 |
Europe/Paris |
2026-08-02 21:30:00+00: Paris is UTC+2 in summer |
2026-01-15 23:30:00 |
Europe/Paris |
2026-01-15 22:30:00+00: UTC+1 in winter |
2026-08-02 |
Europe/Paris |
2026-08-02: a date has no time |
- An unknown zone is refused at
lm validate. - Fusing these dates with an ERP's in an entity: Dates across sources.
A two-digit year
A year written 98 does not say its century. Say where the 100 years start with century-from:
cell:
- parse-date: { column: since, formats: ["MM/dd/yy"], century-from: 1950 }
| In | century-from |
Stored |
|---|---|---|
12/03/98 |
1950 |
1998-12-03 |
01/15/05 |
1950 |
2005-01-15 |
01/01/49 |
1950 |
2049-01-01 |
01/01/50 |
1950 |
1950-01-01 |
12/03/98 |
1900 |
1998-12-03 |
01/15/05 |
1900 |
1905-01-15 |
century-from: 1950readsyyas 1950 to 2049.- A
yyformat withoutcentury-fromis refused atlm validate: no default is right for every source. century-fromwith noyyformat is refused too: it would do nothing.- A four-digit year (
yyyy) needs neither.
parse-number — numbers are 1 234,56 € or (123)
cell:
- parse-number: { column: balance, decimal-separator: ",", negatives-in-parens: true }
| In | Out |
|---|---|
1 234,56 € |
1234.56 |
(123) |
-123 |
$1,234.50 with decimal-separator: "." |
1234.50 |
n/a |
(empty), +1 cell_errors |
It removes currency signs and spaces. A new column is a decimal column (NUMERIC): each value
keeps the decimals it was written with, 3250.00 stays 3250.00.
An integer column: integer: true
An identifier or a count is a whole number. Say so:
cell:
- parse-number: { column: m_product_id, integer: true }
| In | Out |
|---|---|
1024944 |
1024944: an integer |
7.0000 |
7 |
7.1 |
(empty), +1 cell_errors: not a whole number |
parse-boolean — flags are 0/1, O/N or Oui/Non
cell:
- parse-boolean: { column: actif }
| In | Out |
|---|---|
1, true, yes, y, oui, o, vrai, t |
true |
0, false, no, n, non, faux, f |
false |
2, peut-être |
(empty), +1 cell_errors |
Case and spaces do not count. A new column is a boolean: WHERE actif filters it.
A source with its own words lists them. They replace the defaults, not add to them:
cell:
- parse-boolean: { column: statut, true: [A, Actif], false: [I, Inactif] }
A word in both lists is refused when the manifest is checked.
A column is typed when the table is created. Two consequences:
- If the first rows leave the column empty, it is created as text, and stays text.
- Adding
parse-date,parse-numberorparse-booleanto a connector that already ran does not change its column.lm applytells you, with a warning naming the column and its type. Create the table again: the four steps. The next run reads the files again.
Codes and labels
lookup — translate codes with an inline table
cell:
- lookup: { column: is_active, table: { "Y": "true", "N": "false" }, on-missing: null }
| In | on-missing: keep (default) |
null |
error |
INACTIVE (any value) |
|---|---|---|---|---|
Y |
true |
true |
true |
true |
? |
? |
(empty), +1 cell_errors |
the run fails | INACTIVE |
The translated values land as text.
default — fill one blank column
cell:
- default: { column: legal_form, value: "NONE" }
| In | Out |
|---|---|
SARL |
SARL: never overwritten |
| (empty) | NONE |
Emails and phones
parse-email — trim, lowercase, validate
- parse-email: { column: contact_email, lower: true }
| In | Out |
|---|---|
Contact@Example.COM |
contact@example.com |
contact at example |
(empty), +1 cell_errors |
parse-phone — one format for every phone
- parse-phone: { column: phone, default-region: FR }
| In | Out |
|---|---|
06 12 34 56 78 |
+33612345678 |
+1 415 555 2671 |
+14155552671: a full number ignores the region |
abc |
(empty), +1 cell_errors |
Set default-region to the source's country. The default is US: French numbers would land wrong.
Patterns
regex-replace — normalise with a pattern
cell:
- regex-replace: { column: name, pattern: "\\s+", replacement: " " }
| In | Out |
|---|---|
DUPONT SARL |
DUPONT SARL |
- Every match is replaced. Without
replacement, the match is deleted. - In
replacement,$1is the first group. Write a literal$as\\$.
regex-extract — pull a piece into its own column
cell:
- regex-extract:
column: name
pattern: "\\b(SARL|SAS|EURL|SCI)\\b"
groups: { 1: legal_form }
name |
→ legal_form |
|---|---|
DUPONT SARL |
SARL |
Standard |
(empty): no match |
- Each group writes into a new column. A column the row already holds is refused: by
lm validatewhen the file's columns are known before it is read, otherwise by the run, which fails withregex-extract: column '…' is already in the row. To rewrite a value in place, useregex-replace. - A miss is not counted: an optional field misses on many rows. A column that is empty on every row means the pattern never matched.
- Every row must match? Add
required: true: a miss then counts onecell_errors. - Follow it with
defaultto give the misses a value.
Several values in one cell
split — parts into new columns
cell:
- split: { column: tags, by: ";", into: [tag1, tag2, tag3] }
tags |
tag1 |
tag2 |
tag3 |
cell_errors |
|---|---|---|---|---|
retail;online;b2b |
retail |
online |
b2b |
— |
retail;online |
retail |
online |
(empty) | +1: fewer parts |
a;b;c;d |
a |
b |
c |
+1: d has nowhere to go |
The row stays one row. into-array: true writes one array instead.
split-row — one row per value of a;b;c
An order line lists its items in one cell:
order_no,items
ORD-1,TSH-BLEU-M|TSH-BLEU-M|CAP-NOIR
split-row makes one row per item. Two identical items would land as one row. Add position:
each row then carries its place, and both land.
row:
- split-row: { by: items, sep: "|", position: item_no }
Without position |
With position |
|||
|---|---|---|---|---|
order_no |
items |
order_no |
items |
item_no |
ORD-1 |
TSH-BLEU-M |
ORD-1 |
TSH-BLEU-M |
1 |
ORD-1 |
CAP-NOIR |
ORD-1 |
TSH-BLEU-M |
2 |
ORD-1 |
CAP-NOIR |
3 |
- The other columns are copied onto each row.
INandOUTstill count one source row. positionnames a new column.- A separator written inside a value, such as EDIFACT's
?': addescape: "?".
A value written once, for the rows below
fill-down — a value written once, for the rows that follow
A bank statement writes the account number once, on the first row of its block:
account,booked_on,label,amount
FR76-3000-0001,2026-10-01,Solde initial,1200.00
,2026-10-03,Virement client ACME,450.00
,2026-10-05,Prélèvement URSSAF,-320.00
FR76-3000-0002,2026-10-01,Solde initial,800.00
,2026-10-04,Carte bancaire,-42.50
fill-down copies it onto the rows below, until the next account:
mapping:
cell:
- fill-down: { columns: [account] }
$ lm connector run bank-statement
$ lm run get 3df03f1c
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
3df03f1c bank-statement default Completed 5 5 0 0 2026-10-24 22:15 2026-10-24 22:15
lumnik=> select account, booked_on, label, amount from connector.t_bank_statement;
account | booked_on | label | amount
----------------+------------+----------------------+---------
FR76-3000-0001 | 2026-10-01 | Solde initial | 1200.00
FR76-3000-0001 | 2026-10-03 | Virement client ACME | 450.00
FR76-3000-0001 | 2026-10-05 | Prélèvement URSSAF | -320.00
FR76-3000-0002 | 2026-10-01 | Solde initial | 800.00
FR76-3000-0002 | 2026-10-04 | Carte bancaire | -42.50
(5 rows)
Every movement now carries its account. Full example: docs/connectors/csv/70-fill-down.yaml.
Start again at each block: reset-when. An order export writes an H row (the order), then
its L rows (the lines). The second order has no customer:
type,order_no,customer,sku,qty
H,SO-1,ACME,,
L,,,PMP-100,2
L,,,VAN-200,1
H,SO-2,,,
L,,,FLT-300,5
Without care, SO-2 would inherit ACME. reset-when forgets what is carried at each H row:
- fill-down: { columns: [order_no, customer], reset-when: { column: type, in: [H] } }
lumnik=> select type, order_no, customer, sku, qty from connector.t_order_lines;
type | order_no | customer | sku | qty
------+----------+----------+---------+-----
H | SO-1 | ACME | |
L | SO-1 | ACME | PMP-100 | 2
L | SO-1 | ACME | VAN-200 | 1
H | SO-2 | | |
L | SO-2 | | FLT-300 | 5
(5 rows)
- Each file starts with nothing carried.
- A
filter-whereruns before: a row it drops is never carried from. Filter theHrows out later, in a view, not here. fill-downfollows the file's order.lm validaterefuses it besidesort-by.
Full example: docs/connectors/csv/71-fill-down-reset.yaml.
Identifiers
to-uuid — an id column holds junk like N/A or 0
Two knobs: generate decides an empty cell, on-invalid a cell holding junk.
cell:
- to-uuid:
column: customer_id
generate: true # an EMPTY cell gets a new id
on-invalid: keep # a cell holding "N/A" is left alone for you to look at
| The cell holds | generate |
on-invalid |
Result | cell_errors |
|---|---|---|---|---|
| a valid UUID | any | any | unchanged | — |
| nothing | false (default) |
any | stays empty | — |
| nothing | true |
any | a new id | — |
N/A |
any | null (default) |
emptied | +1 |
N/A |
any | generate |
a new id in place of N/A |
+1 |
N/A |
any | keep |
N/A, untouched |
— |
A generated id changes at every run. To keep one id per row, derive it from a column:
cell:
- to-uuid: { column: ext_id, generate-from: code } # F0004 gets the same id every night
on-invalid: generate invents an identifier
The new id replaces a value your source sent, and joins to nothing elsewhere. Only a few rows
dirty? Run once with keep and look at them first.
Sensitive data
mask — a column holds card/PII data
cell:
- mask: { column: card_number, keep-last: 4 }
| In | Out |
|---|---|
4556123498761234 |
************1234 |
contact@example.com with keep-first: 2, keep-last: 4 |
co*************.com |
1234 |
1234: not masked, too short |
Two things to know before trusting mask
- A value not longer than
keep-first + keep-lastlands unmasked. - A dead letter keeps the raw value. A row set aside before the cell tier is shown unmasked
by
lm dlq get.
Provenance and order
enrich-context — stamp provenance on every row
dataset:
- enrich-context: { source_system: siebel, ingested_at: "${now}" }
Every row gains source_system = siebel and the moment of ingestion. An existing value is
never overwritten.
sort-by — order within the chunk
- sort-by: { columns: [c_bpartner_id] } sorts each block of 500 rows, as text, empty values
first. It never sorts the whole file.
Order matters
Three placements to get right
validate-rowbeforesplit-row.lm validaterefuses the other order.regex-extractbeforemask: aftermask, the value is stars.defaultafter theregex-extractwhose misses it fills.
Troubleshooting
| You see | Do this |
|---|---|
| Rows missing, and no dead letter | A filter-where or skip-empty-rows dropped them. Read SKIPPED in lm run get. |
DEAD-LETTERS > 0, reason MalformedSource |
The file itself is broken: wrong column count, unclosed quote. lm dlq get <id> shows the line. Fix the export. |
| A column is empty on some rows, the rows landed | A hook could not read those values. lm run get warns with ⚠ N cell(s) not delivered as declared. |
A regex-extract column is empty on every row |
The pattern never matched. Test it on a real value. |
| The mapping seems to do nothing | You loaded with lm csv ingest, which never transforms. |
lm validate: unknown cell hook: … |
A typo, or the hook is under the wrong tier. The refusal lists the tier's hooks. |
A run fails and says run lm endpoint reset-cursor <connector>/<endpoint> |
The file or the mapping changed while a fill-down run had stopped halfway. Run the command: the next run reads the file again from its start. |
aggregator is not supported on csv-file connector runs |
Group the rows in the source export instead. |
lm validate refuses spec.mapping.cell[N].kotlin |
Kotlin hooks |
See also
- Transformers — reference: every hook and parameter.
- Kotlin hooks: your own code, when no transformer fits.
- CSV & files: the connector these hooks run in.
docs/connectors/csv/in the repository: the runnable manifests.