String Cheese is a small, fast templating engine for Swift. You write text with tags in it, such as an email, a web page, a Markdown report or a config file, and render it against your data. It has the features templates commonly need, values, conditions, loops, variables, functions, formatting and escaping, and no dependencies beyond Foundation.
Hi {% customer.firstName %},
{% foreach item in order.items %}
{% itemIndex + 1 %}. {% item.name %}{% if item.quantity > 1 %} × {% item.quantity %}{% endif %} ... {% (item.price * item.quantity) | currency %}
{% endforeach %}
Total: {% order.total | currency %}{% if order.total >= 50 %} (free shipping){% endif %}import StringCheese
let template = try Template(contentsOf: url)
let email = try template.render(encoding: order) // any Encodable valueHi Mary Ann,
1. Pour-over kettle ... $39.00
2. Single-origin beans, 12 oz × 2 ... $33.00
Total: $72.00 (free shipping)
- Parse once, render many times. A template is parsed into an immutable,
Sendablevalue with every tag's expression compiled up front, so rendering does no parsing, and one template can render on many threads at once. In the benchmarks it renders a page 4 to 84 times faster than Leaf, Mustache and Stencil. - Mistakes show up early. An unclosed
if, a typo in an expression or a call to a function that doesn't exist throws aTemplateErrorwith a line and column when the template is created, not halfway through a render. - Expressions that go beyond lookups. Arithmetic, comparisons,
AND/OR,? :, pattern matching and collection operators like@sumand@count, in a syntax modeled on NSPredicate's. - Built-in functions and formatting. Text, list and number functions, pluralizing, and locale-aware dates, numbers and currency, plus your own functions.
- Renders what you already have.
TemplateValueliterals, Foundation objects from JSON or property lists, anyEncodablevalue, or your own objects read one key at a time. - Any kind of text. Templates don't assume HTML. Turn on HTML escaping when you want it, trim whitespace around tags, and change the delimiters.
- Runs everywhere Swift 6 does, including Linux, with output written to a
String, anyTextOutputStreamor straight to bytes.
Installation · Use · Escaping · Expressions · Conditions · Whitespace · Loops · Variables · Functions · Formatting · Custom functions · Logging · Comments · Objects · Custom expressions · Benchmarks · License
Add String Cheese to your Package.swift:
dependencies: [
.package(url: "https://github.com/mierau/string-cheese", from: "3.0.0"),
],Then add "StringCheese" to your target's dependencies. In Xcode, choose File › Add Package Dependencies and enter the same URL.
String Cheese needs Swift 6 and runs on macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1 and Linux.
String Cheese was called DMTemplates through 2.x. To move to 3.0, point the dependency at the URL above, depend on the StringCheese product, and change import DMTemplates to import StringCheese. Nothing else in the API changed.
Create a template and render it against a context:
import StringCheese
let template = try Template("Hello, my name is {% firstName %}.")
let rendered = template.render(["firstName": "Dustin"])Rendered: Hello, my name is Dustin.
A context is a TemplateValue, which you can write as a literal, as above. You can also render against Foundation or Swift values, such as a dictionary read from JSON or a property list, with render(object:), or against any Encodable value with render(encoding:). For large or expensive values a template only reads part of, see Objects.
Mistakes in a template, such as an unclosed if or a typo in an expression, throw a TemplateError with a line and column when the template is created, rather than surfacing while it renders.
To load a template from a file, use Template(contentsOf:). Tags are written between {% and %} by default; TemplateOptions.beginDelimiter and endDelimiter change them.
For long output, such as a large page or a file, render(_:into:) writes to any TextOutputStream in pieces as it goes, rather than building the whole result as one string first:
var page = ""
template.render(context, into: &page)Servers and files that take bytes can have the UTF-8 handed straight to them instead, with no String in between:
try template.render(context) { bytes in
try handle.write(contentsOf: bytes)
}Templates don't assume they're HTML, so values are written as they are unless you ask for escaping. For HTML or XML templates, turn it on with TemplateOptions.escaping:
var options = TemplateOptions()
options.escaping = .html
let page = try Template("<h1>{% post.title %}</h1>{% post.body | raw %}", options: options)Every value tag then escapes &, <, >, " and '. A value tag ending in | raw is written as it is, for HTML you trust, and one ending in | escape isn't escaped twice. Template text and log tags are never escaped. Escaping { ... } takes your own transform for other formats.
Value tags, conditions and loops all take expressions, written in a syntax modeled on NSPredicate's:
- Key paths:
person.name,people[0],person["first name"],people[FIRST],people[LAST],people[SIZE]. A key path through an array collects the key from every element, sofiles.nameis an array of names. Indexing text gives a character, soname[0]is the first letter. - Collection operators:
@count,@sum,@avg,@minand@max, as inpeople.@avg.age. - Arithmetic:
+ - * / %.+also joins text, as in"Hi " + person.name, whenever either side is text that isn't a number. - Comparisons:
== = != <> < <= > >=,BETWEEN,IN,CONTAINS,BEGINSWITH,ENDSWITH,LIKEandMATCHES, with[c],[d]or[cd]to ignore case or diacritics. - Pattern matching:
LIKEmatches the whole string against a pattern where*stands for any run of characters and?for exactly one, sofile LIKE[c] "*.png".MATCHEStakes a regular expression that has to match the whole string, as incode MATCHES "[A-Z]{3}-[0-9]+". A pattern written in the template is checked when the template is created. - Logic:
AND,ORandNOT, or&&,||and!. - Conditionals:
condition ? then : otherwise, as in{% done ? "✓" : "–" %}. Only the side the condition picks is evaluated. - Literals: strings, numbers,
true/false/YES/NO,nil, and arrays written{1, 2}or[1, 2]. - Exact decimals:
Decimalvalues, such as prices, stay exact through arithmetic with whole numbers and other decimals,@sum,@avg, rounding and formatting. - Function calls:
name.uppercase(),name | prefix(3),path("~", name). See Functions.
ANY, ALL, SOME, NONE, SUBQUERY, CAST and TERNARY are not supported and are reported as errors. Use ? : in place of TERNARY.
Templates support if, elseif and else, closed by endif. else if and a bare end work too.
{% if(person.contacts.@count == 0) %}
Please add some contacts.
{% elseif(person.contacts.@count < 3) %}
Add some more contacts.
{% else %}
You have enough contacts.
{% endif %}The parentheses are optional, so {% if count > 2 %} and {% foreach contact in person.contacts %} work too.
A control tag swallows the line break that follows it, so tags on lines of their own don't leave blank lines behind.
A - just inside a tag's delimiter trims the whitespace on that side of the tag, line breaks included. {%- trims before the tag and -%} after it:
<ul>
{%- foreach item in items -%}
<li>{% item %}</li>
{%- endforeach -%}
</ul>Rendered: <ul><li>a</li><li>b</li></ul>
Because {%- trims, write a negative number at the start of a tag with a space before it, as in {% -1 %}.
A foreach loop runs over an array, which can come from a key path, an expression or an inline array.
{% foreach(contact in person.contacts) %}
Contact name: {% contact.firstName %} {% contact.lastName %}
{% endforeach %}
{% foreach(contactName in {"Dustin", "Mary Ann", "Ollie"}) %}
Contact: {% contactName %}
{% endforeach %}
{% foreach(filename in files.name | lowercase) %}
Lowercase file name: {% filename %}
{% endforeach %}Inside a loop, the current index is available as the loop variable's name followed by Index, and whether it's the first or last element as the name followed by IsFirst and IsLast:
{% foreach(contact in person.contacts) %}
Contact {% contactIndex + 1 %}: {% contact.firstName %}
{% endforeach %}
{% foreach name in names %}{% if !nameIsFirst %}{% nameIsLast ? " and " : ", " %}{% endif %}{% name %}{% end %}Rendered: Dustin, Mary Ann and Ollie
An else inside a loop runs when there's nothing to loop over:
{% foreach contact in person.contacts %}
{% contact.firstName %}
{% else %}
No contacts yet.
{% endforeach %}A loop over a dictionary gets each entry as key and value, in key order:
{% foreach price in prices %}
{% price.key %}: {% price.value | currency %}
{% endforeach %}A set tag stores a value under a name, for the tags after it to use:
{% set total = order.items.@sum.price %}
Subtotal: {% total | currency %}
With tax: {% (total * 1.08) | currency %}A variable hides a key of the same name in the context. Setting a variable that's already set changes it, so a loop can add to a total set before the loop:
{% set count = 0 %}
{% foreach item in order.items %}{% set count = count + item.quantity %}{% endforeach %}
{% count | pluralize("item") %}A variable first set inside a loop belongs to that loop and is gone after it. One set inside an if stays in scope after the endif, so a condition can pick it:
{% if order.isGift %}{% set greeting = "Enjoy your gift" %}{% else %}{% set greeting = "Thanks for your order" %}{% endif %}Functions transform a value before it's shown. Pass a value through them with a pipe, or call them the way you'd call a method in Swift. These all mean the same thing:
{% person.firstName | prefix(1) | uppercase %}
{% person.firstName.prefix(1).uppercase() %}
{% uppercase(prefix(person.firstName, 1)) %}Pipes apply left to right and need no parentheses when there are no arguments. A pipe binds as tightly as ., so it works inside conditions, and parentheses pipe a whole expression:
{% person.bio | trim | escape %}
{% if(post.title | lowercase BEGINSWITH "draft") %}(unpublished){% endif %}
{% (item.price * item.quantity) | currency %}The standard functions are:
- Text:
uppercase,lowercase,capitalize,trim,prefix(n),suffix(n),dropFirst(n),dropLast(n)(wherendefaults to 1),replace(old, new)andtruncate(n), which shortens text toncharacters ending in "…" (ortruncate(n, "...")for another ending). - Counts:
pluralize(singular, plural), as inpost.comments.@count | pluralize("comment")for3 comments;pluraldefaults to the singular plus "s". - Output:
rawleaves a value as it is (see Escaping),escapeescapes text for HTML and XML,urlEncodepercent-encodes everything except letters, digits and-._~, andbytesshows a byte count for people, as in1.9 MB. - Lists:
first,last,count,reverse,unique,sortorsort(key),where(key, value)(orwhere(key)for elements whose key is true), andjoin(separator)to join a list into text. They chain, as inpeople | where("admin") | sort("name") | first. - Numbers:
roundorround(places),floor,ceilandabs. path(components...), as inpath("/avatars", person.id, "photo.jpg").default(fallback), which stands in for nil or empty values, as inperson.nickname | default(person.firstName).
Text functions applied to a list apply to each element, so files.name | lowercase | join(", ") lists every name in lowercase.
Formatting functions show dates and numbers for people:
Posted {% post.date | date %} at {% post.date | time %}
Updated {% post.updated | relative %}
Total: {% order.total | currency %}They are:
date,timeanddateTime, as inOct 9, 2025and1:53 AM.relative, as in2 days ago.iso8601, as in2025-10-09T08:53:20Z, which is also how dates render without a format.number,percentandcurrency, as in1,234.5,25%and$1,234.50.
For anything else, use format with a date pattern like "MMM d, yyyy" or a number pattern like "#,##0.00", written the way DateFormatter and NumberFormatter take them.
{% post.date | format("EEEE, MMMM d") %}
{% item.weight | format("0.0") %} kgFormats follow TemplateOptions.locale, timeZone and currencyCode, which default to the current locale, the current time zone and the locale's currency:
var options = TemplateOptions()
options.locale = Locale(identifier: "fr_FR")
options.currencyCode = "EUR"Dates can be Date values, ISO 8601 strings such as "2025-10-09T08:53:20Z" or "2025-10-09", or numbers of seconds since 1970, so dates in JSON work too. They also compare with <, > and the rest, and work with @min and @max. A value a format doesn't fit, such as a name formatted as a date, passes through unchanged.
Register your own in TemplateOptions.functions. Functions.text makes one from a transform of text:
var options = TemplateOptions()
options.functions["shout"] = Functions.text { $0.uppercased() + "!" }A full function gets the value it's called on and its arguments:
options.functions["initials"] = { receiver, _ in
.string(receiver.renderedString.split(separator: " ").compactMap(\.first).map(String.init).joined())
}{% person.name | shout %} {% person.name | initials %}Any name works, even one like function or all, and a function you register replaces a standard one with the same name. Calling a function that isn't registered is an error when the template is created.
To help debug a template, a log tag writes an expression's value to TemplateOptions.log, which prints to standard error by default. Log tags render nothing, and a render can switch them off by leaving .log out of its features.
{% log(person.firstName) %}A misspelled key renders as nothing. To find typos like that, render with the .strict feature, which reports every key a tag asks for that the context doesn't have, with its line, to TemplateOptions.log. A key that's there with a nil value isn't reported.
template.render(context, features: [.log, .strict])
// line 3: no value for 'naem' in person.naemA tag starting with # is a comment and renders nothing.
{%# Shown on the account page. %}Rendering usually starts by converting the whole context into TemplateValues. For values that are large or expensive to read, of which a template only uses a part, such as model objects backed by a database, conform to TemplateObject instead. The template then asks for each key as it needs it:
struct Account: TemplateObject {
let id: Int
func value(forKey key: String) -> TemplateValue? {
switch key {
case "name": return .string(database.name(of: id))
case "orders": return .array(database.orders(of: id).map { .object($0) })
default: return nil
}
}
}
template.render(["account": .object(Account(id: 7))])Return nil for a key the object doesn't have, which strict mode reports, and .null for one that's there with no value. Where a template uses the object as one value, such as writing it out, looping over it or passing it to a function, it uses the object's templateValue, which defaults to its description.
On Apple platforms, render(object:) reads any other NSObject, such as a Core Data object, the same way, through its @objc properties. Render a Core Data object on its context's queue.
Expressions are compiled through the ExpressionCompiler protocol, and TemplateOptions.expressionCompiler can replace the built-in NativeExpressionCompiler with your own.
String Cheese parses a template once and compiles its expressions, so a render only walks the compiled template and reads the context. To see what that buys, the benchmarks render the same page with String Cheese and three other Swift template engines: Stencil, Mustache (Hummingbird's) and Leaf (Vapor's LeafKit).
The page is a heading and a list of 100 people, each with a name, a city and an (admin) marker shown by a condition. Every engine escapes HTML, and the benchmark checks that each one's output matches String Cheese's, ignoring whitespace, before timing it. Each engine has its own copy of the template, in Benchmarks/Comparison/Fixtures.
Render, parsed once (microseconds per render, lower is better)
| Engine | Linux | macOS |
|---|---|---|
| String Cheese | 52 µs | 136 µs |
String Cheese, render(object:) |
136 µs (2.6× slower) | 232 µs (1.7× slower) |
| Leaf 1.14 | 592 µs (11× slower) | 551 µs (4.1× slower) |
| Mustache 2.1 | 772 µs (15× slower) | 672 µs (4.9× slower) |
| Stencil 0.15 | 4,398 µs (84× slower) | 2,400 µs (18× slower) |
Parse and render (microseconds per render, lower is better)
| Engine | Linux | macOS |
|---|---|---|
| String Cheese | 65 µs | 164 µs |
String Cheese, render(object:) |
152 µs (2.3× slower) | 270 µs (1.6× slower) |
| Leaf 1.14 | 654 µs (10× slower) | 667 µs (4.1× slower) |
| Mustache 2.1 | 801 µs (12× slower) | 780 µs (4.8× slower) |
| Stencil 0.15 | 4,476 µs (68× slower) | 2,175 µs (13× slower) |
Measured on October 12, 2026 in this run of the Benchmarks workflow, on GitHub's hosted runners: Linux is ubuntu-latest (AMD EPYC 7763, 4 cores) with Swift 6.2.4, and macOS is macos-15 (Apple M1, virtual, 3 cores) with Swift 6.1.2. Release builds, 2 seconds per measurement.
Render, parsed once is the usual case for an app or server: the template is created once and rendered for each request. Parse and render creates the template every time. A few things to know when reading them:
- String Cheese renders from a
TemplateValuedecoded from JSON once up front. Itsrender(object:)row renders from the same dictionary of Swift values Stencil and Mustache get, converting it on every render. Leaf gets its ownLeafDatavalues, built once up front. - Stencil tokenizes a template when it's created but parses it on every render, so its two rows are close.
- Leaf renders through SwiftNIO futures. The benchmark runs them on an embedded event loop with the template in memory, so no time goes to threads or files. Parse and render turns Leaf's template cache off.
- These are shared GitHub Actions machines, so expect some noise from run to run. Compare engines within a row rather than across runs.
To run the comparison yourself, run Benchmarks/Comparison/run.sh, which takes the number of seconds to spend on each measurement, 1 by default (CI uses 2). It needs network access the first time to fetch the other engines. The Benchmarks workflow runs it on Linux and macOS for every pull request that touches the library or the benchmarks, and puts the table in the run's summary; you can also start it by hand from the Actions tab.
Benchmarks/run.sh measures String Cheese alone on the templates in Benchmarks/Fixtures: parsing, rendering, rendering from Foundation objects, and rendering one template from every core at once. It has no dependencies, so it's the one to run while working on the engine.
String Cheese is available under the MIT license. See LICENSE.