Domain-Specific Languages
A language restricted to one problem domain, which is what lets it say more with less and refuse to express things the domain considers nonsense. The restriction is the feature; every DSL that grows out of it becomes a general-purpose language with worse tools.
What makes something a domain-specific language rather than just a library with a lot of functions?
A program written in a notation whose vocabulary is the domain's vocabulary rather than a general-purpose language's. What makes it a language rather than a data format is that it has a grammar and an execution or evaluation semantics; what makes it domain-specific is that the grammar deliberately cannot express things outside the domain. That restriction is a representation choice with teeth: it is what allows analyses — a query planner, an exhaustiveness check over the domain's cases, a resource bound — that a general-purpose program would not admit.
A DSL implementation may assume only what its grammar and its checker enforce. That is the whole bargain: SQL's planner may reorder joins because the language cannot express a dependence on evaluation order between them; a regular expression engine may build a DFA because the language cannot express recursion; a build language may run rules in parallel because it cannot express a hidden dependence. Every one of those assumptions becomes illegal the moment the language grows a feature that violates it, which is why feature creep in a DSL is not merely aesthetic.
Key points
- A DSL is a language restricted to one domain; the restriction is what enables analysis, planning and checking that general-purpose code does not admit.
- You already use many: SQL, regular expressions, build files, shaders, HCL, cron, CSS, format strings.
- The line between a library and a DSL is whether a program exists as an inspectable artifact before it runs — not whether it has its own syntax.
- Turing-completeness is not a criterion, and most valuable DSLs are deliberately not Turing-complete.
- Each restriction buys a specific property: no loops buys termination, no state buys reordering, no I/O buys reproducibility.
- Feature creep removes those properties one at a time, and the end state is a general-purpose language with worse tooling.
- The cost is a permanent tooling obligation plus one more language every reader has to learn.
You already use several
The word sounds exotic and the examples are mundane. SQL is a DSL for relational queries. Regular expressions are a DSL for string patterns. Every build file is a program in a build language. CSS selectors are a query DSL over a document tree, CSS itself is a declarative styling language, and a shader is a program in a language that exists because general-purpose code cannot be scheduled across thousands of cores. Terraform HCL, Dockerfiles, Kubernetes manifests, cron expressions, glob patterns, printf format strings, jq filters, Prometheus's PromQL — all of them are languages, and most of them are used daily by people who would say they do not use DSLs.
What they share is not syntax; it is that each was created because expressing the same thing in a general-purpose language was worse *for the reader*, or because the restricted form permitted something the general form did not. Those are the only two reasons that hold up.
The most instructive one is SQL, because both reasons apply at once. It reads closer to a description of the desired result than to a procedure, and — decisively — because it does not specify how to compute the answer, an optimizer is free to choose. That freedom is why the same query gets faster when the data changes shape or the planner improves, with no edit. A hand-written procedural query in a general-purpose language would have fixed the plan at the moment it was written.
| Language | Domain | What the restriction enables |
|---|---|---|
| SQL | Relational query | The engine chooses the plan, because no evaluation order was specified |
| Regular expressions | String patterns | Compilation to a finite automaton with a bounded per-character cost — possible only because there is no recursion |
| Terraform HCL | Infrastructure declarations | A diff between the declared and actual state, because the file describes an end state rather than steps |
| GLSL / WGSL | GPU shading | Massively parallel scheduling, because the language forbids the shared mutable state that would prevent it |
| Make / Bazel rules | Build graphs | Parallel and incremental execution, because dependencies are declared rather than implied by order |
| Cron expressions | Recurring schedules | Total analysability: five fields, a finite language, and the next fire time computable without running anything |
| CSS | Document styling | Incremental re-evaluation on document change, because rules are declarative and order-independent apart from specificity |
Language or library: the line that actually matters
The common answer — "a DSL has its own syntax" — is wrong in both directions. Plenty of things with their own syntax are data formats rather than languages, and plenty of things with no syntax of their own are languages in every sense that matters. The line that holds up is about *analysis*: does the thing exist as an artifact that can be inspected, checked, transformed or planned before it runs?
A library call is executed. A DSL program is a value that can be read. When you write a query with an ORM query builder, the builder constructs an object; if that object is inspected, optimized and translated before anything runs, you have written a program in an internal DSL. If the calls simply execute one by one, you have called a library. That distinction is what determines whether the domain's knowledge can be applied to the program as a whole.
The practical test: can you write a program in it, look at that program without executing it, and say something useful about it? A checker that rejects a nonsensical combination, a planner that reorders it, a formatter that rewrites it, a diff that shows what will change. If yes, it is a language, whatever the syntax looks like. If no, it is an API — which is fine, and considerably cheaper, and usually the right answer.
Note what this test does *not* say. Turing-completeness is irrelevant; most good DSLs are deliberately not Turing-complete. Having a parser is irrelevant; an internal DSL borrows the host's. Being interpreted rather than compiled is irrelevant. Only the existence of an inspectable program is load-bearing.
1// Library: each call does its work immediately. There is no program.2rows = db.select_all("orders")3rows = filter(rows, |r| r.total > 100)4rows = sort_by(rows, |r| r.created_at)5rows = take(rows, 10)6 7// Language: the calls build a value that is analysed before anything runs.8q = orders.where(total > 100).order_by(created_at).limit(10)9plan = optimize(q) // the whole query is visible: index chosen, limit pushed down10rows = execute(plan)11 12// External language: the same program, in its own notation.13SELECT * FROM orders WHERE total > 100 ORDER BY created_at LIMIT 10The first form fetches every order and discards most of them, and no component is in a position to notice. The second and third are the same program in two syntaxes, and both can be planned. The syntax is the least interesting difference between them.
The restriction is the feature
re, Java, JavaScript, .NET — support backreferences and lookaround and use a backtracking engine that is exponential on some inputs. The same pattern text can therefore be safe in one language and a denial-of-service vector in another, which is the sharpest available illustration that the restriction, not the syntax, was the feature.It is tempting to describe a DSL by what it adds — nice notation, domain vocabulary. The durable value is usually in what it removes. A language that cannot loop cannot hang. A language with no I/O cannot have a hidden dependency. A language with no mutable state can be evaluated in parallel or in any order. A language with a closed set of cases can be checked for exhaustiveness over them.
Every entry in the table above is an instance. Regular expressions can be compiled to an automaton with a bounded per-character cost precisely because they cannot recurse — and the moment a regex dialect adds backreferences, that guarantee is gone and catastrophic backtracking becomes possible, which is the source of a whole genre of production incident. The restriction was not a limitation that got fixed; removing it removed the property.
This is why feature creep in a DSL is a technical problem and not just a taste problem. Each added feature is fine in isolation and each one potentially invalidates an assumption some component was relying on. Adding a conditional to a configuration format means it can no longer be validated statically. Adding a function to a build language means the dependency graph may no longer be computable without running the file. The end state is a general-purpose language with a hand-written parser, no debugger and one maintainer — and every language that got there arrived one reasonable feature at a time.
- No loops means no non-termination, so evaluation is guaranteed to finish and can be bounded.
- No mutable state means order-independence, so evaluation can be parallel, incremental or cached.
- No I/O means the program is a pure function of its text, so it can be diffed, replayed and tested trivially.
- A closed case set means exhaustiveness over the domain is checkable, which a general-purpose language cannot offer.
- No specified evaluation order means an optimizer may choose one, which is the entire value of SQL.
Where the cost lives
Everything above is the case for. The case against is that a language is not a feature, it is a product with an indefinite support obligation, and the parser is the small part of it. People who depend on a notation expect syntax highlighting, a formatter, error messages that name a line and a column, completion, a way to debug what a program did, documentation, and a story for what happens when the language changes. That list is [[dsl-tooling-cost]], and it is the reason [[should-i-build-a-dsl]] answers no by default.
The other cost is a second thing to learn. Every DSL in a codebase is a language a new engineer must acquire before they can change anything, and unlike the host language there is no book, no Stack Overflow and no model that has seen a million examples of it. A notation that saves its authors an hour a week and costs every reader a day of onboarding is a bad trade that never shows up in any measurement.
The honest summary is that DSLs are excellent where the domain is stable, the audience is not the host language's programmers, and someone owns the tooling — and a liability everywhere else. That is a narrow target, and the rest of this module is about aiming at it.
How it works
The steps, in the order the compiler takes them.
- Identify the domain's vocabulary and the operations practitioners already name, and take the notation from what they write on whiteboards rather than from the host language.
- Decide what the language must be unable to express, and check each exclusion against a property you want to guarantee — termination, order-independence, static validation.
- Define a grammar over that vocabulary, or an embedding in a host language that produces an inspectable value.
- Build a checker that rejects programs the domain considers nonsense, using the domain's words in the error messages rather than the implementation's.
- Choose an execution strategy — interpret the tree, compile to the host, compile to bytecode, or generate code at build time — see
[[dsl-implementation-strategies]]. - Ship the tooling alongside the language rather than after it, because adoption is bounded by the diagnostics rather than by the semantics.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- The language grows a conditional, then a loop, then functions, and the properties that justified it are gone while the tooling deficit remains.
- Error messages never improve past "unexpected token at line 12", and use of the language stops at the person who wrote it.
- A regex-like guarantee is assumed to hold across dialects, and a pattern that ran in linear time in one language causes a request to hang in another.
- A new engineer cannot change a domain rule because the notation is undocumented and no tool understands it, so the change is made by editing generated output instead.
- The author leaves, and a language that half the system depends on becomes infrastructure nobody is willing to modify.
- The DSL is embedded in strings inside the host language, so the host's tools see only text: no highlighting, no checking, no completion, and a syntax error reaching production.
When it helps
- Stable domains with an established notation practitioners already use — queries, schedules, patterns, grammars, layout, hardware description.
- Where the audience is not the host language's programmers: analysts, operators, hardware engineers, domain experts.
- Where the restriction buys a real property: a plan the engine chooses, a bound on execution, a static diff of what will change.
- Where the same specification must drive several outputs — validate, execute, document, visualise — which requires the program to be an inspectable artifact.
When it hurts
- When the domain is still moving, so the notation freezes a design that is still being discovered.
- When the readers are the same engineers who already work fluently in the host language.
- When nobody is budgeted to own the tooling, which makes the language's ceiling its first error message.
- When the programs are only ever machine-generated, in which case a schema-validated data format does the job with far better tooling.
What it costs
Every one of these is paid by something.
- Domain notation buys concision and readability for people who know the domain, and pays a permanent learning cost for everyone who does not, with no external documentation, no community answers and no tool that has seen it before.
- Restricting the language buys analysability — planning, bounding, static validation, parallel evaluation — and pays expressiveness: the first genuinely necessary case the language cannot express becomes a permanent, visible wart or a breaking change.
- A separate artifact buys the ability to check, diff, plan and version domain logic independently of the application, and pays a second toolchain in the build with its own failure modes and its own CI.
- Choosing a library instead buys the host's entire ecosystem for free and pays in notation: the domain logic reads as host-language code, and domain errors stay run-time errors rather than becoming static ones.
What else you could do
What a different compiler or language does instead, and when that is better.
- A well-designed library or API in the host language, which is the right answer most of the time — see
[[should-i-build-a-dsl]]for what changes that. - A data format with a schema — JSON, YAML or TOML validated against JSON Schema. Excellent tooling for free, and it stops working the moment the domain needs abstraction; see
[[configuration-languages]]. - An internal DSL embedded in the host language, which gets much of the notation while inheriting the host's toolchain — see
[[internal-vs-external-dsl]]. - Code generation from a specification, where the specification is the DSL and the output is ordinary host code that the host's tools debug normally.
See it for yourself
The flag, dump or tool that shows you this directly.
- Ask a SQL engine to show you the program it built:
EXPLAINorEXPLAIN ANALYZEprints the plan, which is the compiler output for a language most people do not think of as compiled. terraform planprints the diff between declared and actual state — the analysis a declarative language makes possible and an imperative script does not.- For regular expressions, compare
re.compile(...)in Python with Go'sregexpon a pattern with a backreference: one accepts it and can backtrack exponentially, the other rejects it outright, and the difference is the language design. - Look at a build system's graph:
bazel query --output=graphormake -pnshows the dependency structure the language was designed to make declarable. - For an internal DSL, print the object a query builder produces before executing it. If it is inspectable, it is a program; if execution has already happened, it was a library.
Plausible wrong readings
Stated the way a confident engineer states them.
- "A DSL is anything with custom syntax." Data formats have syntax and are not languages; internal DSLs have no syntax of their own and are.
- "A DSL has to be Turing-complete to be a real language." The best ones deliberately are not, and the guarantees they offer depend on that.
- "SQL is not a programming language, it is a query language." It is a language with a grammar, a semantics and a compiler that produces a plan — it is one of the most successful DSLs ever designed.
- "If it is only used internally, the tooling does not matter." Internal users have the same expectations and less patience, and they cannot search the web for your error message.
- "We already have a DSL, it is just some YAML with templating." That is a language with no parser, no types and no diagnostics, which is the expensive end of this decision rather than the cheap one.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A domain-specific language is a small language built for one job — querying data, matching text, describing a build, declaring infrastructure. It uses the words of that domain and deliberately cannot express things outside it. That limitation is the point: because a SQL query does not say how to fetch the rows, the database is free to work out the fastest way, and because a regular expression cannot call itself, it can be matched in predictable time.
practical
When you meet one, ask what it cannot express and what that buys — it will tell you more about the design than any tutorial. When you are tempted to build one, notice that you are proposing to own a language: a parser, error messages with line and column, a formatter, editor support, docs and a migration story, for as long as anyone uses it. And when someone says "it is just YAML with a bit of templating", recognise that as a language with none of those things, which is the most expensive point on the whole spectrum.
advanced
The most useful way to read a DSL is as a set of *assumptions the implementation is allowed to make*, with the syntax as an afterthought. SQL's planner exists because the language cannot specify join order. Bazel's parallelism exists because the language cannot express an undeclared dependency. RE2's time bound exists because the language cannot recurse. Read that way, every feature request is a request to give up an assumption, and the discipline of a good DSL owner is being able to name which one. It also explains the failure pattern precisely: a language accretes features, each removes an assumption, and eventually the implementation is doing nothing the host language could not do while the ecosystem deficit remains — the DSL has become a general-purpose language with a worse debugger. [[configuration-languages]] traces exactly that trajectory through half a dozen real formats.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
RETURNING, upsert syntax, NULL ordering and type coercion all differ between PostgreSQL, MySQL, SQLite and SQL Server. A DSL having a specification does not mean programs in it are portable, which is a lesson worth carrying into any DSL intended to outlive one implementation.If you were asked this in an interview
- Name three DSLs you used this week and say what each one deliberately cannot express.
- What distinguishes a DSL from a library with a fluent API?
- Why is it a problem for a configuration language to gain a conditional?
Connections
- DevOps / Production Engineering — Build languages and infrastructure languages as production tooling that teams operate dailyThis lesson treats a build file or a Terraform module as a program with a grammar and a semantics. Running those systems — what a plan means during an incident, how state is stored, who is allowed to apply — is an operations subject that starts where the language design stops.