Ludwig Syntax Reference

Click here to return to the Advanced Ludwig table of contents.

Structure of a Composition

Ludwig compositions consist of expressions, such as imports, type definitions, and variable bindings. This very simple example shows us the structure of a typical composition:

# A composition statement

# An import
import Fugue.AWS

# A type definition
type Tag:
  key: String
  value: String

# A binding
Tag vpcTag: {
  key: "Name",
  value: "Value"


Bindings are the most common element in a Ludwig composition. A binding consists of a name, a body, and optionally a type annotation. It’s a good practice to add type annotations for all named function definitions, and wherever type annotations increase readability.

# With type annotation
String greeting: "Hello world!"

# Without type annotation, but same binding, value, and type
greeting: "Hello world!"

Bindings always start with a lowercase character.

Primitive types

Ludwig is statically-typed, which means that every expression has a type that is checked at compile time. Ludwig types can roughly be split into two categories: primitive types and user-defined types.

Let’s illustrate the primitive types.


A standard machine integer.


  • Int myInt: 1

A double-precision floating point number.


  • Float myFloat: 3.14

A Unicode string surrounded by single or double quotes.

Strings can span multiple lines, and lines are joined by a single space character. Or, you can use \ on the next line to preserve new lines.


  • With double quotes: String myString1: "Hello world!"
  • With single quotes: String myString2: 'Hello world!'
  • Spanning multiple lines:
String myLongString1:
     "This is a line
     and this is a continuation"

String myLongString2:
     "This is a line
     \and this is a new line"

Boolean value.


  • Bool myBool1: True
  • Bool myBool2: False

Lists preserve ordering and are homogeneous, so they can contain values of any single type.


  • List of Ints: List<Int> myList: [1, 2, 3]
  • This list would produce a compiler error because “foo” is not an Int: List<Int> myMistake: [1, 2, "foo"]

Dictionaries have no explicit ordering; think of them as hashmaps. Keys are always strings, and values can be any primitive type. Like lists, dictionaries are homogeneous, so all values must be of the same type.


  • Dictionary of Ints: Dictionary<Int> myDictionary: {"foo": 1, "bar": 2}

Binary data read from an external file.


  • Bytes myBytes: Ludwig.Bytes.readFile("/etc/passwd")

Optional type. The Optional type “wraps” one of the above types, and just means that the value doesn’t have to be specified. You would usually see optionals within records.


Both of these are valid assignments (though not in the same composition):

  • Optional<Int> myOptInt: Optional(23)
  • Optional<Int> myOptInt: None

All types (primitive but also user-defined) start with an uppercase character.


Defining records

In addition to primitive types, it is possible to use records in Ludwig. A record holds one or more key-value pairs and can be declared in either JSON- or YAML-style syntax.

# JSON-style syntax
prod: {
  region: AWS.Us-west-1,
  instanceSize: EC2.M4_large

# YAML-style syntax
  region: AWS.Us-east-1
  instanceSize: EC2.M3_medium

The types of these bindings are, unsurprisingly, record types. These are written like this:

{region: AWS.Region, instanceSize: EC2.InstanceType} prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

Types like this can become unwieldy to read and write very quickly. That is why the type keyword allows you to introduce an alias. An alias is just a synonym for a type. Here, in JSON-like syntax, you can see the alias Environment standing in for the full record type in the third line:

type Environment: {region: AWS.Region, instanceSize: EC2.InstanceType}

Environment prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

In a similar way to the records themselves, we can also define types in YAML-like syntax in addition to the JSON-like syntax above.

type Environment:
  region: AWS.Region
  instanceSize: EC2.InstanceType

Bindings, Records, and Fields

What’s the difference between a binding, a record, and a field?

Briefly, a binding “binds” a value to a name. Here is a diagram of a binding:

devRegion:      AWS.Us-east-1
^^^^^^^^^^      ^^^^^^^^^^^^^
name/variable   value


The name and value together comprise the binding.

Sometimes that value is another variable:

devRegion:      region
^^^^^^^^^^      ^^^^^^
name/variable   value (variable)


It can also be a record, which holds one or more key-value pairs:

devRegion:      {key: region}
                      value (key value)
^^^^^^^^^^      ^^^^^^^^^^^^^
name/variable   value (key-value pair)


As for the term field, it’s often used as a synonym for “a key and its value.” For instance, you can say this production-vpc record contains the fields cidrBlock, instanceTenancy, and region:

production-vpc: {
  cidrBlock: "",
  instanceTenancy: EC2.DefaultTenancy,
  region: AWS.Us-west-2,

Selecting from records

Fields can be selected from a record using the record.field-name syntax, e.g.:

EC2.InstanceType prod-instance: prod.instanceSize

Updating records

Records can be updated using the with keyword. You can override one or more fields at a time. Below, prod-2 and prod-3 use with to update the prod record. prod-2 updates the region and keeps the same instanceSize. prod-3 updates both fields.

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-2: prod with {region: AWS.Ca-central-1}

prod-3: prod with {region: AWS.Us-east-1, instanceSize: EC2.T1_micro}

That’s the same as writing this:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-2: {region: AWS.Ca-central-1, instanceSize: EC2.M4_large}

prod-3: {region: AWS.Us-east-1, instanceSize: EC2.T1_micro}

Nested record values can also be updated. If we have the following types and environment:

type Environment:
  region: AWS.Region
  instanceSize: EC2.InstanceType
  tag: Tag

type Tag:
  key: String
  value: String

Environment dev: {
  region: AWS.Us-east-1,
  instanceSize: EC2.M3_large,
  tag: {
    key: "Name",
    value: "Development Environment"

Then we can create a dev2 with a new tag value like so:

Environment dev2: dev with { tag.value: "Development Environment 2" }

A Closer Look at “Updating”

We say records can be updated, but the word “update” is a misnomer. Records are immutable, which means they cannot be changed. When you use with to “update” a record, you’re actually creating a copy of the record and then modifying the copy.

Let’s return to the prod example. We updated prod by creating a copy of it, naming the copy prod-2, and modifying the copy:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-2: prod with {region: AWS.Ca-central-1}

When this is compiled, prod remains unchanged. We can prove this if we use the -s tree flag to compile the composition and pipe the output into jq:

lwc Composition.lw -s tree | jq .
  "prod": {
    "ludwig-metadata": {
      "id": "prod",
      "type": "struct"
    "ludwig-value": {
      "instanceSize": {
        "ludwig-metadata": {
          "tag": "Fugue.Core.AWS.EC2.M4_large",
          "type": "sumcon"
        "ludwig-value": null
      "region": {
        "ludwig-metadata": {
          "tag": "Fugue.Core.AWS.Common.Us-west-1",
          "type": "sumcon"
        "ludwig-value": null
  "prod-2": {
    "ludwig-metadata": {
      "id": "prod-2",
      "type": "struct"
    "ludwig-value": {
      "instanceSize": {
        "ludwig-metadata": {
          "tag": "Fugue.Core.AWS.EC2.M4_large",
          "type": "sumcon"
        "ludwig-value": null
      "region": {
        "ludwig-metadata": {
          "tag": "Fugue.Core.AWS.Common.Ca-central-1",
          "type": "sumcon"
        "ludwig-value": null

So although we say we’ve “updated” prod, the record itself hasn’t changed. You can also see that the copy, prod-2, has the same instanceSize as prod but contains a different region.

Extending and Restricting Records

The with keyword not only allows you to update records – it also lets you extend them by adding new fields or restrict them by deleting existing fields.

You can extend a record by using the with keyword and prefacing each new field with a plus sign. Below, prod-extended creates a copy of prod and then modifies the copy, adding the new field availabilityZone:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-extended: prod with {+availabilityZone: AWS.A}

That’s the same as this:

prod-extended: {availabilityZone: AWS.A, region: AWS.Us-west-1, instanceSize: EC2.M4_large}

You can restrict a record by using the with keyword and prefacing each field you want to delete with a minus sign. Here, prod-restricted creates a copy of prod and modifies the copy, removing the instanceSize field:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-restricted: prod with {-instanceSize}

That’s the same as this:

prod-restricted: {region: AWS.Us-west-1}

You can update, extend, and restrict a record in the same binding:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-update-extend-restrict: prod with {region: AWS.Eu-west-1, +availabilityZone: AWS.A, -instanceSize}

The result changes the region value, adds the availabilityZone field, and removes the instanceSize field all at once:

prod-update-extend-restrict: {availabilityZone: AWS.A, region: AWS.Eu-west-1}

Under the Hood: Updates

Behind the scenes, an update is just the combination of a restriction and an extension. The old field is removed, then the new field is added. So prod-a is exactly the same as prod-b:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-a: prod with {region: AWS.Ca-central-1}

prod-b: prod with {-region, +region: AWS.Ca-central-1}

prod-a demonstrates an update, and prod-b demonstrates a restriction and extension. Both bindings end up with the same value:

{region: AWS.Ca-Central-1, instanceSize: EC2.M4_large}

Under the Hood: Scoping


This is an advanced topic! It provides record system details that advanced users may find useful or interesting, but it’s not required reading.

Ludwig allows a record to contain multiple fields with the same label. These fields may be of the same or different types. If two fields in a record share the same label, we say the label is scoped. You can scope a label by adding another field via record extension.

Below, prod-scoped extends the prod record by adding a new region field of Bool type, which will coexist with a region field of AWS.Region type:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-scoped: prod with {+region: True}

This is the value of prod-scoped, behind the scenes:

{region: True, region: AWS.Us-west-1, instanceSize: EC2.M4_large}

The result has two region fields, so this means region is scoped.

In Ludwig, new fields are always added to the beginning of the record, record restrictions always delete the first field matching a label, and a record selection grabs the first field that matches a label. Any others are ignored. So if we used prod-scoped.region to select a region value in the above example, we’d get region: True because it’s listed first.

Records with scoped labels can be updated, extended, or restricted, just like normal unscoped records.

You can remove scoping, too. Building on the last example, prod-scoped has two region fields, so region is scoped. We can restrict the record to delete one of the fields, leaving us with a single (unscoped) region field:

prod: {region: AWS.Us-west-1, instanceSize: EC2.M4_large}

prod-scoped: prod with {+region: True}

prod-unscoped: prod-scoped with {-region}

prod-scoped adds the region: True field, and fields are always added to the beginning. prod-unscoped deletes the first field matching a label, which is region: True. As a result, the value of prod-unscoped is exactly the same as prod, so we’re back where we started:

{region: AWS.Us-west-1, instanceSize: EC2.M4_large}
Things to Be Aware Of

You cannot scope a label by simply declaring a record that has two fields with the same name; the compiler will fail with a “Duplicate field” error. You can only scope a label through a record extension.

Additionally, JSON and other lwc output formats require unique fields, so trailing fields in a label are discarded during compilation. The output will only contain one value per label.

It’s unlikely you’ll ever need to deliberately scope or descope a label, but it may be useful to understand the underlying mechanics, especially if you intend to extend or restrict records.


Ludwig also has tuples:

my-tuple: ("Hello", 123, True)

And tuples can be given types:

(String, Int, Bool) my-typed-tuple: ("Hello", 123, True)

Algebraic data types

Defining algebraic data types

Ludwig supports algebraic data types. In the simplest form, these are just untagged enumerations:

type Environment:
  | Prod
  | Stage
  | Develop

myEnvironment: Stage

However, they can also carry a value.

type EnvironmentPreference:
  | NoPreference
  | SpecificEnvironment Environment

myPreferredEnvironment: SpecificEnvironment(myEnvironment)

If multiple values are required, use a tuple or a record:

type Point: (Int, Int)

type MyEnumType:
    | NeedRecord {field1: Int, field2: String}
    # value types can be defined inline
    | NeedTuple (Int, String)
    # or in an type
    | NeedTuple2 Point

MyEnumType value1: NeedRecord({field1: 33, field2: "Hello"})
MyEnumType value2: NeedTuple((33, "Hello"))
MyEnumType value3: NeedTuple2((0,0))

Pattern matching

You can see what’s inside an algebraic data type or a tuple value by pattern matching:

env: case myEnvironment of
    | Prod    -> 10
    | Stage   -> 12
    | Develop -> 15

Pattern matching can be used to great effect within functions as well.

fun install {platform: Platform, packages: List<String>} -> String:
  case platform of
    | Debian -> String.join(" ", List.concat(["apt-get -q -y install"], packages))
    | RHEL _ -> String.join(" ", List.concat(["yum -q -y install"], packages))

It is also possible to match on the values inside the constructors.

havePreference: case myPreferredEnvironment of
    | NoPreference          -> False
    | SpecificEnvironment _ -> True

preferenceOrDefault: case myPreferredEnvironment of
    | NoPreference          -> Develop
    | SpecificEnvironment c -> c

Using the example above of myPreferredEnvironment: SpecificEnvironment(myEnvironment):

  • havePreference(myPreferredEnvironment) evaluates to True, as myEnvironment is a SpecificEnvironment.
  • preferenceOrDefault(myPreferredEnvironment) evaluates to Stage for the same reason; if myPreferredEnvironment were the NoPreference value, the result would be a default of Develop.

Record Updates Inside Constructors

We can also safely update values that may be inside of sum type constructors using the same familiar syntax for records. If we have this:

type Foo:
  | Foo {foo: String}

bar: Foo {foo: "foo"}

Then we can create a new binding by updating the interior foo value like so:

baz: bar with { "baz" }

Record Selects Inside Constructors

Similar to updating values inside of sum type constructors, we can also do selects. The restrictions here are that:

  1. The sum type only has a single constructor.
  2. That single constructor has a value inside.

This syntax is consistent with updates:

type Foo:
  | Foo {foo: String}

Foo bar: Foo({foo: "foo"})
String qux:



Ludwig supports defining and calling functions. Let’s look at a simple example of a function with an explicit type.

type Tag:
    key: String
    value:  String

fun get-value(p: Tag) -> String: p.value

We can call this function using familiar syntax:

example-value: get-value({key: "Name", value: "Production Environment"})

There are two “flavors” of function in Ludwig. The one above is the positional argument flavor – noted by its use of parentheses () in the function definition. There’s also a named argument flavor, which is defined with curly braces {}. Usage is very similar, just that in the named argument flavor, the caller must use named arguments.

fun get-value-named-argument{p: Tag} -> String:

example-value-two: get-value-named-argument {
  p: {key: "Name", value: "Staging Environment"}

Anonymous functions

Ludwig also supports anonymous functions. For example:

get-anon: fun(p): p.value

The type of this anonymous function is:

fun (Tag) -> String

Function types are useful when we want to store functions in data types.

type Curve:
    name: String
    y:    fun(Int) -> Int

Curve quadratic:
    name: "The simple quadratic curve"
    y:    fun(x): x * x

Shorthand syntax

In most cases you must provide () (positional) or {} (keyword) when calling a function. However, there is a shorthand syntax available for calling functions that take a string or a record as a single argument.

foo1: get-anon{value: "Develop Environment"}   # Same as get-anon({value: "Develop Environment"})
foo2: String.length "Develop Environment"     # Same as String.length("Develop Environment")
foo3: String.length"Develop Environment"      # Same as String.length("Develop Environment")


In addition to Pattern matching, Ludwig also has traditional if-elif-else conditionals.

fun getRegionFromEnvironment(env: Environment) -> AWS.Region:
    if then AWS.Us-west-1 else AWS.Us-east-1

You can put the branches on different lines, but in that case it is important that the indentation of if and else matches.

fun getRegionFromEnvironmentSeparateLines(env: Environment) -> AWS.Region:
    then AWS.Us-west-1
    else AWS.Us-east-1

Any number of elif expressions can be added between the if and else branches.

fun getRegionFromEnvironmentComplex(env: Environment) -> AWS.Region:
    if         then AWS.Us-west-2
    elif      then AWS.Us-west-1
    else AWS.Us-east-1

However, there must always be an else branch. The following two examples are illegal:

fun getRegionFromEnvironment(env: Environment) -> AWS.Region:
    if then AWS.Us-west-1

fun getRegionFromEnvironmentComplex(env: Environment) -> AWS.Region:
    if         then AWS.Us-west-2
    elif      then AWS.Us-west-1


Ludwig has some basic operators built in.

There is basic integer arithmetic, using the +, -, *, and / operators:

foo: 10 / 2

bar: foo + 1

There is a parallel set of floating point operators, which are like the integer operators but preceded with a ., namely .+, .-, .*, and ./.

There is string concatenation, using the ++ operator:

hello: "hello" ++ "world"

Ludwig also has the usual &&, ||, and ! boolean operators.

Local variables

You can define local variables within functions or bindings. To do this, we use the let keyword. The line after the let keyword must have the same indentation.

    let x: 1
    x + 2

In this example, foo will be bound to the value 3.

You can declare multiple local variables after one another. We also support type annotations and function declarations, very similar to top-level bindings.

    let fun add-some(x: Int) -> Int: x + 2
    let y: 1
    1 + add-some(y)

List comprehensions

Ludwig supports Python-inspired list comprehensions.

numbers: [i for i in Int.range{to: 10}]

These can be mixed with if conditions.

odd-numbers: [i for i in numbers if i % 2 == 0]

Additionally, you can use patterns in the for part of a list comprehension. This works just like Pattern matching. Since you can also call functions from list comprehensions, this makes for some very powerful combinations:

import Fugue.AWS.EC2 as EC2

# For example, if you have a list of optionals...
List<Optional<String>> optionals:
  [String.getEnv("AMI01"), String.getEnv("AMI02")]

# You can create an instance with only the not-None elements by pattern
# matching on it.
  [ {image: ami, ...} for Optional ami in optionals]

Annotations and Pragmas

There are a handful of field annotations and directive pragmas in the Ludwig language, and they use a similar syntax. Users of Fugue don’t need to write code using these annotations and pragmas, but some of them have relevant meaning when you read them. We’ll explain what annotations matter to you, and which don’t, below.


The @mutable annotation is one that appears in Fugue.* libraries. It indicates a field that, when changed in a composition, is modified by Fugue upon an update command. If the field is not labelled as @mutable, modifying it will cause the resource to be “destructively updated,” meaning that the resource will be replaced with a new one.


The @deprecated annotation also appears in Fugue.* libraries. It indicates that a given item is deprecated and may be applied to types, constructors, functions, operators, and even entire modules. If a composition references a deprecated item, the lwc compiler sends a warning to stderr, like this one:

ludwig (warning):
  "UseDeprecatedModule.lw" (line 1, column 8):
  module Deprecated-import1 is deprecated:

    1| import Deprecated-import1 as .

    Use another module.

lwc tolerates warnings by default, so if there are no errors, the composition still compiles. When using the lwc compiler by itself, you can turn warnings off with --no-warnings or opt to fail on warnings with --werror. See The Ludwig Compiler for more details.


The @proto annotation is one used internally by Fugue. It cannot be usefully applied by Fugue users at this time.


The @language pragma may be used to specify extensions that modify how a given Ludwig file is processed by lwc. For example, it may be used to enable implicit imports mode, like so:


For a full explanation of explicit vs. implicit imports with examples, see Explicit Imports.

Another extension, @language("no-prelude"), enables no-prelude mode. Internally, no-prelude mode is used within the Ludwig.Prelude modules to prevent them from importing themselves. Most users will never need to implement it. However, if you are developing your own version of the types defined in the prelude, you can enable no-prelude mode to test your version. For example, if you tried to redefine the Validation type in a new module – perhaps to add another constructor – you’d encounter a compiler error because the type already exists and is automatically imported into your composition. But if you disable import of the prelude types by including @language("no-prelude") at the beginning of the module, the compiler won’t throw an error at the duplicate definition, so you can test the module normally.

The @language("allow-module-cycles") extension suppresses compiler warnings on cyclic modules. Cyclic modules are interdependent; the import and export of types between two modules form a cycle during compilation.

For example, here’s module A.lw:

import B
two: + 1

And here’s module B.lw:

import A
three: A.two + 1
one: 1

Module A imports B and exports two, which uses the type one from B. Module B imports A and exports three and one; three uses the type two from A (which uses the type one from B). These imports form a cycle.

By default, the compiler emits a warning message when module imports form a cycle. Both A.lw and B.lw would raise the warning when compiled. However, you can suppress the warning by adding @language("allow-module-cycles") at the beginning of each cyclic module. Most Ludwig users won’t need to implement this @language extension, but it gives advanced users more control in how they structure code in their modules.


The @override annotation is used to selectively override a binding at compile-time. If a field in a composition is marked with @override, you can use the Ludwig compiler’s --set option to pass in a different value when that composition is compiled. For more information, see Overriding a binding in a composition.


What are modules?

A module in Ludwig is currently represented by a file. For example, the file Basic.lw provides the Basic module. Modules can be nested hierarchically using directories, so the file Network/Ip.lw provides the Network.Ip module.

As you can see, module names (and thus files containing modules) always start with an uppercase character.

In order to use such a module, we use the import keyword. For example,

import Basic
import Network.Ip

makes all the type definitions and data declarations in the files Basic.lw and Network/Ip.lw available in the present file. To refer to them, you would write or Alternatively, if writing the fully qualified name is a bit long, you can alias a module:

import Basic as B

This makes types and exports from Basic available under the prefix B; e.g., B.Foo. Finally, you can also import the names into the current scope:

import Basic as .

This means you can now just use foo instead of This is often very useful, although you have to watch out for naming conflicts.

If you have many imports, indentation-based syntax allows you to type the import keyword only once.

    Network.Ip as .
    Foo as F

There’s also some syntactic sugar for dealing with a lot of nested imports. Instead of writing:

    Fugue.AWS.Foo as Foo

you can use the following shorthand syntax:

import Fugue.AWS.{EC2, IAM, Foo as Foo}

Module directories

Where Ludwig looks for the files Basic.lw and Network/Ip.lw is determined by the search path. The compiler searches these paths in order and uses the first it finds:

  1. The current directory.
  2. The directories in the LUDWIG_PATH environment variable, e.g:
LUDWIG_PATH=lib/ludwig:~/.ludwig lwc main.lw

3. Additional directories passed in with an -i flag, e.g.
lwc -i lib/ludwig:~/.ludwig lwc main.lw

4. In C:\Program Files\Fugue\lib on Windows, and /opt/fugue/lib on OSX/Linux systems.

If you need to know which files are actually used, you can dump this info by passing -dmodules to lwc.

Controlling exports

By default, everything in a module is exported. This can be fine-tuned by using export lists. If one or more export lists occur in the file, only the things explicitly mentioned in the lists are exported.

We use the export keyword to define such a list.

    type Environment     # Export the type Environment
    default-environment  # Export a variable or function

default-environment: ...

type Environment: ...

You can also re-export whole modules. This is a bit more complicated, since we can nest them. Let’s look at an example. Imagine we have a module Mod.lw:

export module Foo as Foo  # Export in a nested way
export module Bar as .    # Export in this module's scope

import Foo
import bar

And then another module importing this Mod.lw:

import Mod

foo:  # Because Foo was exported as Foo in Mod
bar:      # Because Bar was exported as . in Mod


When running lwc with the --composition flag (which is the default when using Fugue), we expect a single composition as argument. A composition is a special kind of module, much like a “main” module in other languages.

A composition has a composition keyword, usually at the start of the module. For example, in MyComp.lw:


import Fugue.AWS

my-vpc: ...

If you use lwc with the -s simple semantic backend, all bindings declared in the composition are visible in the output, while bindings declared in other (imported) modules are not. Note: By default, lwc uses the null semantic backend, which produces no output if there are no errors or warnings.


Did you know that Fugue offers editor plug-ins so you can read and write Ludwig in your editor/IDE of choice? All plug-ins include Ludwig syntax highlighting, and some have additional features. See ludwig-mode for Emacs, ludwig-vim for vim, vscode-language-ludwig for the Ludwig LSP Server and VSCode, and language-ludwig for Atom.