Transform Reference
ODIN Transform 1.0, Declarative Data Transformation
Overview
ODIN Transform extends the ODIN notation to define data transformations between ODIN documents and external formats. A transform file is itself a valid ODIN document that describes how to convert data, the notation transforms itself.
Transform definitions are declarative mappings: source paths map to target fields through copy operations, transformations, and structural directives. The same transform engine handles fixed-width records, XML hierarchies, delimited files, and JSON output.
Design Principles
- Declarative, Mappings describe what, not how
- Unidirectional, One transform file, one direction
- Self-describing, Operations are explicit in the syntax
- Composable, Import and reuse common mappings
- Format-agnostic, Same concepts across output formats
- Traceable, Source and target schemas documented
- Deterministic, Same input produces same output
ODIN as a Canonical Data Model
ODIN serves as the Canonical Data Model (CDM) for all transformations. Regardless of source and target formats, data flows through ODIN as the intermediate representation.
Why ODIN as CDM?
- Type Preservation, ODIN's self-describing types (
#,##,#$,?,@,^,~) preserve semantic meaning - Modifier Flow, Field modifiers (
!critical,*confidential,-deprecated) travel with data - Canonical Form, Enables hashing, signatures, and deduplication
- Schema Validation, ODIN Schema validates the canonical form
A transform declared as json->json actually executes as json->odin->json.
Direction Header
Every transform declares its direction in the {$} header:
{$}
odin = 1.0.0
transform = 1.0.0
id = org.example.transform.json.policy
name = Policy to JSON
direction = odin->jsonDocument Structure
Transform documents use the .transform.odin extension and include several standard headers.
The {$source} section describes the incoming data format, while {$target} controls how the output is rendered.
{$source}
schema = org.odin.auto.policy/1.0.0
format = "json"
discriminator = @.recordType ; For multi-record-type inputs
{$target}
format = json
encoding = utf-8
indent = ##2The discriminator enables multi-record processing, when input contains
mixed record types, the engine routes each record to the matching segment based on the
discriminator field value.
Confidential Field Enforcement
The :confidential modifier flags a field as containing sensitive information, Social Security
numbers, API keys, medical records, financial account numbers, or any data subject to privacy regulations.
On its own, the modifier does not alter values. It travels with the data as metadata, signaling to downstream
systems, loggers, and audit trails that the field requires special handling.
{Customer}
SSN = @.ssn :confidential ; Flagged, value passes through unchanged
API_Key = @.apiKey :confidential ; Downstream systems know to mask in logs
Name = @.name ; Not confidential, no special handlingWhen you need the transform itself to protect confidential values before they leave,
set enforceConfidential in the header. This activates enforcement --
every field marked :confidential will have its value redacted or masked
in the output.
{$}
enforceConfidential = "redact" ; or "mask"
{Customer}
SSN = @.ssn :confidential ; Value becomes null (redact) or "***-**-****" (mask)
Name = @.name ; Not confidential, passes through normallyenforceConfidential | Behavior |
|---|---|
| (not set) | No enforcement, confidential values pass through with the * modifier as metadata |
"redact" | All confidential values become ~ (null) |
"mask" | Confidential strings become asterisks (same length), numbers and booleans become ~ |
This two-layer design lets you flag fields once and control enforcement per transform. A transform feeding an internal data warehouse might leave values intact (just flagged), while a transform generating a partner export redacts them.
Constants & Accumulators
Constants define reusable values that stay fixed throughout the transform. Use them for default values, version strings, or any value referenced in multiple places -- change it once in the header instead of hunting through mappings.
{$const}
DEFAULT_STATE = "TX"
POLICY_VERSION = "2024.1"
MAX_VEHICLES = ##10
; Reference with @$const.NAME
state = @policy.state :default @$const.DEFAULT_STATEAccumulators are mutable counters that update as the transform processes records. They're useful for running totals, record counts, and summary fields that appear in headers or trailers.
{$accumulator}
record_count = ##0
premium_total = #$0.00
; Update with %accumulate
_ = %accumulate record_count ##1
_ = %accumulate premium_total @coverage.premium
; Read with @$accumulator.name
total_records = @$accumulator.record_countLookup Tables
Lookup tables embed reference data directly in the transform, replacing what would
otherwise require database joins or external API calls. Define columns in the header,
then use %lookup to search by any column and return any other column.
{$table.STATUS[name, code]}
active, "A"
pending, "P"
cancelled, "C"
; Lookup by name, return code
status_code = %lookup STATUS.code @.statusName
; Reverse lookup (same syntax)
status_name = %lookup STATUS.name @.statusCode{$table.TERRITORY[state, zip_prefix, territory, region]}
TX, 787, "AUSTIN", "CENTRAL"
TX, 750, "DALLAS", "NORTH"
; Match on multiple columns
territory = %lookup TERRITORY.territory @.state @.zipPrefixSymbol Reference
| Symbol | Name | Meaning |
|---|---|---|
@ | Copy | Direct copy from source path |
@. | Current | Current loop item |
@$accumulator | Accumulator | Reference accumulator value |
@$const | Constant | Reference constant value |
% | Transform | Apply built-in verb |
%& | Custom | Apply custom namespaced verb |
"..." | Literal | Constant string value |
##N | Integer | Constant integer value |
#N | Number | Constant numeric value |
#$N | Currency | Constant currency value |
: | Modifier | Modifier follows |
Mapping Syntax
Every line in a transform body is a mapping: target = expression.
The left side names the output field, the right side defines where the value comes from --
a source path, a literal, or a verb transformation.
Direct Copy
The @ symbol copies a value from the source data to the target field.
Use dot notation to navigate nested objects.
policy_number = @policy.number
insured_name = @insured.name.lastConstant Values
Assign literal values directly. Strings use quotes, integers use ##,
numbers use #, and currency uses #$.
record_type = "01"
count = ##0
amount = #$100.00Transformations
The % symbol invokes a built-in verb that transforms the value.
Verbs take source paths, literals, or other verb results as arguments.
effective = %formatDate @policy.effective "YYYYMMDD"
name = %concat @name.last ", " @name.first
code = %lookup STATUS @policy.statusNested verbs evaluate inner-to-outer:
display_name = %upper %concat @first " " @lastModifiers follow the value expression:
policy_number = @policy.number :pos 0 :len 15 :rightPad " "
state = @policy.state :default "TX"
amount = @premium :requiredTransformation Verbs
70+ built-in verbs organized into nine groups. Each page includes syntax tables, examples, and "Try It" buttons that open examples in the Transform Lab.
String & Case Conversion Verbs
Concatenation, extraction, padding, masking, and case conversion verbs for string manipulation.
Numeric Verbs
Arithmetic operations, rounding, formatting, and mathematical functions.
Financial & Statistical Verbs
Time value of money calculations (compound interest, loan payments, future/present value) and statistical functions.
Date & Time Verbs
Date formatting, parsing, arithmetic (add days/months/years), and date difference calculations.
Conditional Verbs
Branching logic, null/empty handling, multi-way switch, and comparison operators.
Lookup Verbs
Table lookup operations with single-key, multi-key, and reverse lookup support.
Aggregation Verbs
Accumulator operations and array aggregation functions (sum, count, min, max, avg).
Array Verbs
Array indexing, filtering, sorting, deduplication, and transformation.
Type Coercion & Encoding Verbs
Type conversion (string, number, boolean, date), Base64 encoding, URL encoding, and hashing.
Custom Verbs
Custom verbs use the %& prefix with a reverse-domain namespaced identifier: %&com.acme.sha256 @document.content. Namespaces under org.odin.* are reserved for official extensions.
Field Modifiers
Modifiers follow a mapping expression and control how the value is positioned, formatted, validated,
or conditionally included. They use the : prefix and can be chained: @value :pos 0 :len 10 :rightPad " " :required.
Positioning Modifiers (Fixed-Width)
| Modifier | Syntax | Description |
|---|---|---|
:pos | :pos N | Start position (0-based) |
:len | :len N | Field length |
:leftPad | :leftPad "c" | Left-pad character |
:rightPad | :rightPad "c" | Right-pad character |
:truncate | :truncate | Truncate to length |
policy_number = @policy.number :pos 0 :len 15 :rightPad " "
year = @vehicle.year :pos 15 :len 4 :leftPad "0"XML Modifiers
| Modifier | Syntax | Description |
|---|---|---|
:element | :element | Emit as XML element |
:attr | :attr | Emit as attribute |
:ns | :ns prefix | Namespace prefix |
:cdata | :cdata | Wrap in CDATA |
:omitEmpty | :omitEmpty | Omit if empty |
{Policy}
id = @policy.id :attr
Number = @policy.number
Description = @policy.description :cdata
Notes = @policy.notes :omitEmptyJSON Modifiers
| Modifier | Syntax | Description |
|---|---|---|
:type | :type string|number|boolean | Force JSON type |
:omitNull | :omitNull | Omit if null |
:omitEmpty | :omitEmpty | Omit if empty string/array |
:raw | :raw | Emit raw JSON (no escaping) |
:array | :array | Force as single-element array |
:object | :object {key=@path} | Inline object construction |
year = @vehicle.year :type number ; "2024" -> 2024
active = @is_active :type boolean ; "true" -> true
code = @zip :type string ; 78701 -> "78701"
contact = :object {name = @insured.name, phone = @insured.phone}Conditional Modifiers
| Modifier | Syntax | Description |
|---|---|---|
:if | :if path | Include if truthy |
:if | :if path = value | Include if equals |
:unless | :unless path | Include if falsy |
:default | :default value | Fallback value |
:required | :required | Error if missing |
state = @policy.state :default "TX"
premium = @premium :required
discount = @discount :if hasDiscount
legacy_field = @old_field :unless isNewFormatValidation Modifiers
| Modifier | Syntax | Description |
|---|---|---|
:validate | :validate "regex" | Regex validation |
:enum | :enum v1,v2,v3 | Enum validation |
:range | :range min..max | Range validation |
:confidential | :confidential | Mark for confidential enforcement |
email = @contact.email :validate "^[^@]+@[^@]+$"
status = @status :enum "A,P,C"
year = @year :range 1900..2100Segments
Segments organize transform output into logical sections, header records, detail lines, trailer summaries, or nested object groups. Each segment can have its own loop, condition, and context, making it straightforward to produce structured output from flat or hierarchical input.
{segment.HDR}
; Header record fields
{segment.DET}
; Detail record fieldsArray Segments with Loop
{segment.VEH[]}
:loop vehicles
vin = @.vin
year = @.yearNested Loops
{segment.COV[]}
:loop vehicles :as veh
:loop .coverages :as cov
vehicle_vin = @veh.vin
coverage_code = @cov.codeSegment Directives
| Directive | Syntax | Description |
|---|---|---|
_pass | _pass = ##N | Execute in pass N |
:type | :type "value" | Handle records where discriminator equals value |
:loop | :loop path | Iterate array |
:loop | :loop path :as alias | Iterate with alias |
:counter | :counter name | Loop counter |
:from | :from path | Set context |
:if | :if expr | Conditional segment |
:literal | :literal | Literal block mode |
Format Configuration
The {$target} header controls how the transform engine renders output.
Each format has its own set of options that affect layout, encoding, and edge-case handling.
JSON
Try in Transform LabJSON is the most common target format. These options control how the output handles whitespace, null values, and empty collections.
{$target}
format = json
indent = ##2 ; Pretty-print with 2-space indentation
nulls = omit ; Drop fields with null values from output
emptyArrays = include ; Keep empty arrays ([] ) in output| Setting | Type | Default | Description |
|---|---|---|---|
indent | Integer | 0 | Number of spaces for indentation. 0 produces compact single-line JSON, 2 or 4 produces human-readable output. |
nulls | omit | include | include | When set to omit, fields whose value resolved to null are dropped entirely from the JSON output rather than appearing as "field": null. |
emptyArrays | omit | include | include | When set to omit, array fields that contain no elements are dropped from the output rather than appearing as "field": []. |
encoding | String | utf-8 | Character encoding for the output. |
XML output supports declarations, namespaces, and element-level control. Use field-level :attr and :cdata modifiers (see Field Modifiers above) to control
how individual values are rendered.
{$target}
format = xml
declaration = true ; Include <?xml version="1.0"?> at top
indent = ##2 ; Pretty-print with 2-space indentation
omitEmpty = true ; Skip elements with no value
{$target.namespace}
ins = http://www.example.org/insurance/xml/| Setting | Type | Default | Description |
|---|---|---|---|
declaration | Boolean | true | Include the <?xml version="1.0" encoding="..."?> declaration at the top of the output. |
indent | Integer | 0 | Number of spaces for indentation. 0 produces compact XML. |
omitEmpty | Boolean | false | When true, elements with empty or null values are omitted from the output entirely. |
rootElement | String | (auto) | Name of the root XML element. If not specified, the engine generates one from the segment structure. |
encoding | String | utf-8 | Character encoding declared in the XML header. |
Namespaces are declared in a separate {$target.namespace} section. Each entry maps
a prefix to a URI. Fields in the transform can then use :ns prefix to assign elements
to a namespace.
CSV output writes one row per record. The field order in the transform determines column order.
Use header to control whether a header row with field names is written first.
{$target}
format = csv
delimiter = "," ; Field separator (comma, pipe, tab, etc.)
quote = "\"" ; Character used to wrap fields containing delimiters
header = true ; Write a header row with column names
lineEnding = \n ; Line ending (\n or \r\n)| Setting | Type | Default | Description |
|---|---|---|---|
delimiter | String | , | Character separating fields. Use | for pipe-delimited, \t for tab-delimited. |
quote | String | " | Character wrapping fields that contain the delimiter, newlines, or the quote character itself. |
header | Boolean | true | When true, the first output row contains column names derived from the target field names. |
lineEnding | String | \n | Line ending sequence. Use \r\n for Windows-style line endings. |
Fixed-Width
Try in Transform LabFixed-width output produces records where each field occupies an exact number of characters.
This format is common in insurance, banking, and government data exchange (ACORD, NACHA, EDI).
Use :pos and :len modifiers on individual fields to control positioning.
{$target}
format = fixed-width
lineWidth = ##200 ; Total characters per line
padChar = " " ; Fill unused positions with spaces
truncate = true ; Truncate values that exceed :len
lineEnding = \r\n ; CRLF line endings (common in mainframe formats)| Setting | Type | Default | Description |
|---|---|---|---|
lineWidth | Integer | (auto) | Total characters per output line. When set, every line is padded to exactly this width. When omitted, line width is determined by the field positions. |
padChar | String | " " | Default character used to fill positions not covered by field values. Individual fields can override with :leftPad or :rightPad. |
truncate | Boolean | false | When true, values longer than the field's :len are silently truncated. When false, overflow produces a warning. |
lineEnding | String | \n | Line ending sequence. Many legacy systems expect \r\n. |
Error Configuration
Error settings control how the transform engine responds to problems during execution. These apply across all formats and determine whether a bad field stops the entire transform, produces a warning, or is silently skipped.
{$target}
onError = fail ; fail | warn | skip
onMissing = skip ; fail | warn | skip | default
onValidation = fail ; fail | warn | skip| Setting | Values | Default | Description |
|---|---|---|---|
onError | fail | warn | skip | fail | What happens when a verb or expression throws an error. fail aborts the transform, warn logs it and continues, skip silently omits the field. |
onMissing | fail | warn | skip | default | skip | What happens when a source path doesn't exist in the input. default uses the field's :default modifier value if one is set. |
onValidation | fail | warn | skip | fail | What happens when a :validate, :enum, or :range check fails. |
Key Interactions
:requiredoverridesonMissing, always fails if missing:default "value"provides fallback whenonMissing = default:optionalexplicitly marks field as skippable
Imports & Composition
Transforms can import shared lookup tables, constants, and mapping fragments from other files. This avoids duplication when multiple transforms share common reference data or field mappings. Imported files are merged into the current transform at parse time.
@import ./tables/common.odin
@import ./mappings/base.odin as baseShared Lookup Tables
;; tables/territories.odin
{$table.TERRITORY[state, zip_prefix, territory, region]}
TX, 750, "DALLAS", "NORTH"
TX, 787, "AUSTIN", "CENTRAL"
; ... thousands of entries ...
;; transforms/policy-export.odin
@import ./tables/territories.odin
{segment.VEH[]}
:loop vehicles
territory = %lookup TERRITORY.territory @.state @.zipPrefixShared Mappings
;; mappings/address.odin
{@address}
line1 = @.line1
city = @.city
state = @.state
zip = @.zip
;; transforms/customer-export.odin
@import ./mappings/address.odin as addr
{customer.billing}
= @addr.address :from @.billing_address