AI/TLDR

What Is BAML? A Typed DSL for LLM Functions

You will understand how BAML lets you define LLM functions in a dedicated DSL that compiles to type-safe Python, TypeScript, or Go with reliable structured outputs.

INTERMEDIATE10 MIN READUPDATED 2026-06-14

In plain English

When you call a language model from normal code, the prompt is usually a plain string buried inside your app, and the model's reply comes back as raw text you have to parse and hope is shaped right. Change a word in the prompt and nothing warns you. Ask for JSON and the model occasionally returns prose, a trailing comma, or a field you never asked for. You only find out at runtime, in production.

BAML — illustration
BAML — static.spring.io

BAML (from the company Boundary, pronounced "bammel") takes a different stance: a prompt and the shape of its answer are really a function, so write them like one. You describe the LLM call in a small dedicated language — its own .baml file — saying what inputs it takes, what output type it returns, and what the prompt looks like. Then a compiler reads that file and generates ordinary, type-safe code in Python, TypeScript, or Go that you call like any other function.

Think of it like a database. You don't hand-write the network packets to talk to Postgres; you write SQL or a schema, and tooling generates a typed client. BAML does the same thing for LLM calls. The prompt and its expected result live in one clear place, the compiler turns them into a function with a real return type, and your application just calls extractResume(text) and gets back a typed Resume object — not a string you have to babysit.

Why it matters

Most teams start by writing prompts as f-strings and parsing replies by hand. That works for a demo and quietly rots as the app grows. BAML exists to fix the specific pains that show up once an LLM call becomes real software.

  • Prompts hidden inside code. A prompt scattered across string concatenation is hard to read, hard to review, and easy to break with an innocent edit. Moving it into a dedicated file makes the prompt a first-class artifact you can see whole, diff in a pull request, and reason about.
  • No types on the way out. A raw model reply is just text. Your editor can't autocomplete its fields and your compiler can't catch a typo like resume.emai. BAML generates a real return type, so the output of an LLM call gets the same static checking as the rest of your code.
  • Flaky structured output. Asking a model for JSON and getting almost-JSON is a classic failure. BAML ships its own forgiving parser tuned for model quirks (markdown fences, stray prose, mild formatting drift) so more replies turn into valid objects instead of crashing your parser.
  • One prompt, many languages. A backend in Python and a frontend tool in TypeScript would otherwise duplicate the same prompt and schema twice, drifting apart over time. BAML generates a client for each language from the same definition, so there is one source of truth.

Who cares about this? Anyone building production features on top of an LLM where the output feeds other code: extraction (resume → structured fields), classification, tool/agent calls, or any "turn this messy text into a clean object" task. If you only ever read the model's prose with your eyes, you don't need it. If a downstream function depends on the shape of the answer, a typed, tested LLM function is worth a lot.

BAML sits alongside the provider SDKs rather than replacing your model. You still call OpenAI, Anthropic, Gemini, or a local model — BAML just owns the layer between "here is a prompt" and "here is a validated, typed result", and it does it the same way regardless of which provider is behind it.

How it works

BAML has three pieces: the .baml source files where you declare your types and functions, the compiler that turns them into generated client code, and the runtime that actually makes the call, repairs the model's reply, and hands you a typed object. The key idea is a build step: you don't ship .baml files to production, you ship the code they generate.

Step 1 — Declare the shape and the function

In a .baml file you first define the output type as a class (its fields and their types), then a function that names its inputs, its return type, the model client to use, and the prompt. The prompt is a template: {{ ctx.output_format }} is a special slot where BAML injects a precise description of your output type, so the model is told exactly what schema to produce.

resume.bamltext
class Resume {
  name string
  email string
  skills string[]
  years_experience int
}

function ExtractResume(raw_text: string) -> Resume {
  client "openai/gpt-4o"
  prompt #"
    Extract the candidate details from the text below.

    {{ ctx.output_format }}

    Resume text:
    {{ raw_text }}
  "#
}

Step 2 — Compile to a typed client

Running the BAML generator reads every .baml file and emits source code in your target language: a Resume type (a Python dataclass / Pydantic model, a TypeScript type, or a Go struct) plus a function ExtractResume you can import. You commit this generated code to your repo, so your editor, type-checker, and teammates all see the real types — no magic at runtime.

Step 3 — Call it like any function

Now the LLM call is just a function call. You pass a string, you get back a Resume object with autocomplete and type-checking. Behind the scenes the runtime renders the prompt, calls the model, then runs BAML's tolerant parser to coerce the reply into the declared type.

app.pypython
from baml_client import b   # generated by the BAML compiler

resume = b.ExtractResume(raw_text)   # one network call to the model

# `resume` is a typed object, not a string:
print(resume.name)              # str  — editor autocompletes this
print(resume.skills)            # list[str]
print(resume.years_experience) # int  — already parsed, not "5"

The repair step is what makes this reliable. Instead of demanding perfect JSON, BAML's parser (the team calls the technique Schema-Aligned Parsing) reads the model's raw text, strips markdown fences and chatter, fixes mild structural slips, and aligns the result to your declared type. A reply that a strict JSON.parse would reject often still becomes a valid Resume. If it genuinely cannot satisfy the type, you get a clear typed error rather than a silent half-object.

BAML vs inline prompts vs schema libraries

There are three common ways to get structured data out of an LLM. They overlap, so it helps to see what each actually owns.

ApproachWhere the prompt livesHow you get typesReliability of output
Inline strings + manual parseScattered in app codeNone — you parse text by handYou write all the repair logic yourself
Schema library (e.g. Instructor, Zod)Still in app code, near the callFrom a code-defined schemaValidate-and-retry against the schema
BAML (DSL + codegen)In a dedicated .baml fileGenerated client types per languageBuilt-in tolerant parser, then typed error

The honest distinction: a schema library like Instructor (Python) or a Zod-based helper (TypeScript) keeps everything in your application language — you define the schema in code and the library validates the reply, often retrying on failure. That is lighter to adopt and needs no build step. BAML goes further by pulling the prompt out of code into its own language with its own tooling: syntax highlighting, a playground to test prompts, and one definition that generates clients for several languages at once. The trade is a compile step and a new file type to learn.

When to reach for BAML (and when not to)

BAML earns its place when LLM calls are load-bearing infrastructure, not a one-off script. A few signals that it is a good fit:

  • You have several LLM functions that you keep tweaking — extraction, classification, routing — and you want them version-controlled, reviewable, and testable in one place.
  • The output feeds other code, so its shape really matters and a silent field rename would cause a bug downstream.
  • You work across languages — a Python service and a TypeScript frontend that must share the exact same prompt and schema.
  • You want a fast feedback loop on prompts: the BAML playground lets you run a function against test inputs and see the parsed result without redeploying your app.

When is it overkill? A quick prototype, a single throwaway prompt, or a chat feature where you just stream prose back to a human and never parse it. There the extra file and build step buy you little — a plain SDK call or a small schema library is simpler. BAML's value scales with the number and importance of your structured LLM calls, not with how clever any single prompt is.

Going deeper

Once the basics click, a few features and caveats are worth knowing before you lean on BAML in production.

Schema-Aligned Parsing instead of constrained decoding. Some structured-output systems force valid JSON by restricting which tokens the model may emit (constrained decoding). BAML deliberately does not do that. It lets the model generate freely, then parses and aligns the text to your schema afterward. The bet is that forcing the grammar can hurt answer quality and only works on models you control the decoder for, whereas a tolerant post-parse works with any provider's API. Knowing this explains both its flexibility and the occasional case where a reply is too far off to coerce.

Provider independence and retries. Because the model is just a client line in the .baml file, you can switch from one provider to another by editing one place, and you can define fallback clients and retry policies declaratively. The generated function call in your app does not change. This makes BAML a useful seam for resilience — if one model is down or rate-limited, the runtime can fall back without touching application logic.

Testing and observability. BAML treats test cases as part of the function definition: you can attach example inputs and run them, which turns prompt engineering into something closer to ordinary testing. It also exposes hooks for logging and tracing each call, so you can see the rendered prompt and the parsed result when something misbehaves.

The honest trade-offs. BAML adds a new language and a code-generation step to your build, which is real cognitive overhead and one more thing in CI. The DSL is young and evolving, so syntax and features move faster than a decade-old library. And it does not make a weak prompt smart — if the model genuinely lacks the information, no parser can invent it; you will simply get a clean typed error instead of garbage. The win is not magic accuracy, it is turning fragile string-and-parse glue into a function with a type, a test, and one obvious place to edit. From there, the natural next steps are mastering structured outputs in general and deciding between JSON mode and true structured outputs at the API level.

FAQ

What does BAML stand for?

BAML stands for Basically, A Made-up Language. It is a small domain-specific language from the company Boundary (BoundaryML), built for one purpose: defining LLM functions and their output schemas, then compiling them into type-safe client code.

What languages does BAML generate code for?

From a single .baml definition, the BAML compiler can generate typed clients for Python, TypeScript, and Go (with more targets over time). That means one prompt and schema can be shared across a backend and a frontend without duplicating it.

How is BAML different from Instructor or Zod?

Instructor and Zod-based helpers keep your prompt and schema inside your application code and validate the model's reply against a code-defined schema. BAML moves the prompt and schema into a dedicated DSL file and runs a compile step that generates typed clients, plus it ships its own tolerant parser. BAML adds a build step in exchange for cross-language code generation and prompt tooling.

Does BAML guarantee valid JSON from the model?

Not by forcing the decoder. BAML lets the model answer freely, then uses its Schema-Aligned Parsing to clean up and align the reply to your declared type — it handles markdown fences, stray text, and mild formatting slips. If a reply truly cannot satisfy the type, you get a clear typed error rather than a broken object.

Do I need a build step to use BAML?

Yes. You write .baml files and run the BAML generator, which produces the typed client code you import. You commit that generated code, so the types are visible to your editor and type-checker. This compile step is the main difference from a pure runtime library.

Can BAML stream structured output?

Yes. BAML can stream a partial typed object as the model's tokens arrive, so you can render a half-complete result that is still typed against your schema, rather than waiting for the whole response. See LLM streaming explained for the general idea.

Further reading