Author: vkorobkov
OverviewFstache started from a Python web application built with server-side rendering, HTMX, Tailwind CSS, and Alpine.js.
That stack followed the HTML-over-the-wire style used by tools like HTMX and Hotwire/Turbo: keep rendering on the server, send HTML pages or fragments to the browser, and use JavaScript only for the interactions that genuinely need to happen on the client.
For many full-stack applications, this is a lower-cost and simpler default than building a separate client-side application. There is less state to duplicate between backend and frontend, fewer moving parts to operate, and less framework-specific code between the data and the HTML.
It is also friendly to backend-oriented developers. A small team can build useful, interactive web interfaces while staying close to the backend model, routing, validation, and deployment flow they already understand.
Mustache fit that approach because it is deliberately simple: templates render the data they receive. They do not become another layer for application logic, database access, or framework-specific behavior.
The problem showed up on a $4/month VPS. Rendering a full page with Chevron could take 5-10 ms. Smaller fragments were faster, but full-page rendering was still common enough to matter.
Looking for a faster drop-in renderer led to mstache, which was much faster than Chevron in that application. That raised the next question: how fast could a pure-Python Mustache renderer be while staying simple, dependency-free, and compatible with normal Mustache templates?
Fstache exists to answer that question for this style of Python web development: simple Mustache templates, no runtime dependencies, streaming-friendly output, and enough speed that rendering whole pages or fragments stays practical on modest servers.
InstallationPackage page: fstache on PyPI
Quick StartCreate templates/hello.mustache:
Render it:
import fstache render = fstache.create_renderer("./templates") result = render("hello.mustache", {"name": "Ada"}) print(result.to_string())
Template names match file paths under the template root, including the file extension by default.
Output:
CLIUse fstache render when you want a small shell-friendly render step:
fstache render --data data.json < page.mustache > page.html
The command reads the root template from stdin, reads JSON data from --data,
writes rendered bytes to stdout, and writes diagnostics to stderr. For quick
renders, pass the data inline with --json:
fstache render --json '{"name":"Ada"}' < page.mustache > page.html
--data and --json are mutually exclusive. Stdin always contains the root
template, so --json - does not read data from stdin. If neither data option is
provided, the command renders with {}.
Partials resolve from the current working directory:
{{> shared/header.mustache}}Use --remove-extension when partial names omit the template extension:
fstache render --data data.json --remove-extension < page.mustache > page.html
Set --extension for another template file extension. The leading dot is
optional:
fstache render --data data.json --extension .html --remove-extension < page.mustache > page.html
If an interpolation variable or partial template is missing, fstache render
still writes the rendered result to stdout using an empty value for the missing
tag. It also writes a clear diagnostic to stderr and exits with status 1.
Keep templates under one root:
templates/
├── pages/
│ └── home.mustache
└── partials/
└── header.mustache
Create the renderer once at application startup, then reuse it:
import fstache render = fstache.create_prod_renderer("./templates") def render_home(data: object) -> bytes: return render("pages/home.mustache", data).to_bytes()
For streaming web responses, pass the rendered chunks to your framework:
from starlette.responses import StreamingResponse async def homepage(request): result = render("pages/home.mustache", {"name": "Ada"}) return StreamingResponse( result.iter_chunks(), media_type="text/html; charset=utf-8", )
For local development, use create_dev_renderer("./templates") so template
edits are picked up without restarting and missing data fails fast. For tests,
use create_test_renderer("./templates") to keep missing templates and
variables strict while still preloading templates once.
HttpResponse and
StreamingHttpResponse in a standalone uv project.HTMLResponse and
StreamingResponse in a standalone uv project.Response objects in a standalone uv project.HTMLResponse
and StreamingResponse in a standalone uv project.Choose one filesystem factory, keep the returned renderer around, and call it with a template name plus render data:
render = fstache.create_prod_renderer("./templates") page = render("pages/home.mustache", data)
| Need | Use |
|---|---|
| Edit templates locally and catch missing data early. | create_dev_renderer("./templates") |
| Run tests that fail on missing templates or variables. | create_test_renderer("./templates") |
| Render in production with preloading, compact output, and empty missing values. | create_prod_renderer("./templates") |
| Mix defaults yourself. | create_renderer("./templates", ...) |
create_renderer is the full factory:
render = fstache.create_renderer( "./templates", extension=".mustache", remove_extension=False, delimiters=fstache.DEFAULT_DELIMITERS, ignore_indents=False, left_trim_source=False, preload_templates=True, resolve_missing_template=fstache.resolve_missing_template_as_error, resolve_missing_variable=fstache.resolve_missing_variable_as_none, escape=fstache.html_escape, )
Renderer calls return a RenderedTemplate. Use .iter_chunks() for streaming
bytes, .to_bytes() when you need one bytes value, and .to_string() for
CLI output, tests, and debugging.
Supported Mustache tags include escaped and unescaped variables, dotted names,
sections, inverted sections, variable and section lambdas, partials, dynamic
partial names such as {{> * partial_name}}, comments, and delimiter changes.
Inheritance is intentionally unsupported.
create_renderer preloads templates by default. It reads and compiles matching
template files when the renderer is created, so later file edits are not visible
until you create a new renderer:
render = fstache.create_renderer("./templates")
For local development, create_dev_renderer disables preloading by default so
template edits, partial edits, and new template files are picked up without
restarting the process. If you use create_renderer directly, set
preload_templates=False:
render = fstache.create_renderer("./templates", preload_templates=False)
Avoid preload_templates=False in production. It reads and compiles templates
during rendering, repeats filesystem work on each request, and delays syntax
errors until the template is rendered.
Symlinked templates are allowed when their resolved target stays inside the
template root. If a requested root template or partial resolves outside that
root, it is treated as missing and uses resolve_missing_template.
The filesystem factories constrain stable paths to the template root, but they are not a filesystem sandbox. A concurrent writer can replace a validated directory with a symlink before it is read and redirect Fstache outside the root. See issue #6.
Live loading exposes this window on each template load; preloading limits it to renderer construction but does not eliminate it. Keep production templates and their parent directories immutable or writable only by trusted identities. If untrusted concurrent writes are unavoidable, isolate rendering with a container or an operating-system policy such as Landlock, SELinux, or AppArmor.
PartialsPartial tags load other templates from the same template root:
templates/
├── pages/
│ └── home.mustache
└── partials/
├── footer.mustache
└── header.mustache
Below is the content of the templates/pages/home.mustache:
{{> partials/header.mustache}} <main> <h1>{{title}}</h1> </main> {{> partials/footer.mustache}}
render("pages/home.mustache", {"title": "Dashboard"})
By default, standalone partials inherit the indentation before the partial tag. For example, this template:
Begin.
{{> name.mustache}}
End.with name.mustache:
renders as:
Set ignore_indents=True to skip that standalone partial indentation:
render = fstache.create_renderer("./templates", ignore_indents=True)
Set left_trim_source=True to remove leading spaces and tabs from every
template source line before parsing. It applies to root templates, partials,
and lambda templates:
render = fstache.create_renderer( "./templates", ignore_indents=True, left_trim_source=True, )
So this source indentation:
Begin.
{{> name.mustache}}
End.renders to:
Inline whitespace is preserved. For example, {{first}} {{second}} still
renders with the two spaces between values.
Use compact output for whitespace-insensitive output such as HTML where source
indentation is mostly for template readability. In the workstation benchmark,
ignore_indents=True rendered 4066.5 pages/sec versus 3102.2 pages/sec
with standard standalone partial indentation.
create_renderer discovers .mustache files by default. Set extension when
your templates use another file extension:
templates/
└── pages/
└── home.html
render = fstache.create_renderer("./templates", extension=".html") render("pages/home.html", data)
The leading dot is optional, so extension="html" behaves the same way.
With remove_extension=True, renderer calls and partial tags omit the file
extension. For example, pages/home renders templates/pages/home.mustache:
render = fstache.create_renderer("./templates", remove_extension=True) render("pages/home", data)
create_renderer renders missing variables as empty by default. To fail fast:
render = fstache.create_renderer( "./templates", resolve_missing_variable=fstache.resolve_missing_variable_as_error, )
create_renderer raises MissingTemplateError when a root template or partial
is missing. To render missing templates as empty:
render = fstache.create_renderer( "./templates", resolve_missing_template=fstache.resolve_missing_template_as_empty, )
Pass a custom escape function when escaped variables need output-specific
escaping. The function receives raw bytes and must return escaped bytes:
import fstache def escape_brackets(value: bytes) -> bytes: return ( fstache.html_escape(value) .replace(b"[", b"[") .replace(b"]", b"]") ) render = fstache.create_renderer("./templates", escape=escape_brackets)
The escape hook applies to escaped variable tags such as {{name}}. Unescaped
tags such as {{{name}}} and {{& name}} bypass it.
Use Delimiters when templates start with a non-default tag pair:
render = fstache.create_renderer( "./templates", delimiters=fstache.Delimiters(start=b"[[", end=b"]]"), )
Custom delimiters are the initial parser delimiters. Delimiter-change tags in template source can still update the active pair while parsing.
Low-Level Compile and RenderUse compile and render directly when your application owns template loading,
caching, or precompiled templates:
import fstache templates: dict[str, fstache.CompiledTemplate] = { "greeting": fstache.compile(b"Hello, {{name}}!\n", name="greeting"), } def load_template(name: str) -> fstache.CompiledTemplate: return templates[name] result = fstache.render("greeting", {"name": "Ada"}, load_template) print(result.to_string())
compile(...) accepts template bytes and returns an opaque
CompiledTemplate. Treat it as a value passed to loaders, renderers, missing
template resolvers, and inline_partials, not as a public node tree.
render(...) receives a root template name, render data, and a TemplateLoader
callback. It returns RenderedTemplate, so consume the result with
.iter_chunks(), .to_bytes(), or .to_string().
Pass any Python object as render data. The most common choices are dictionaries and application objects:
from dataclasses import dataclass @dataclass(frozen=True) class User: name: str data = { "site_name": "Docs", "user": User(name="Ada"), } render("profile.mustache", data)
Variable lookup starts in the current section scope and falls back to parent
scopes. Mapping values use key lookup, while other objects use attributes and
properties. Dotted names follow each part, so {{user.name}} works for both
{"user": {"name": "Ada"}} and {"user": User(name="Ada")}.
Sections use normal Python truthiness. Missing and falsey values skip
{{#section}} bodies and render {{^section}} bodies. Lists and tuples repeat
the section once per item, using each item as the current scope. Truthy mappings
and objects enter the section as a child scope; True renders the body without
changing scope.
Variable values are rendered as bytes:
str values are UTF-8 encoded.None and missing variables render as empty by default.str(value).bytes and memoryview values are rendered as byte chunks, avoiding string
conversion and letting unescaped streaming output reuse the original bytes.Escaped tags such as {{value}} apply the configured escape function, which may
copy the bytes. Unescaped tags such as {{{value}}} and {{& value}} write
bytes, memoryview, and embedded render-result chunks unchanged.
Renderer calls return a RenderedTemplate:
.to_bytes() joins the rendered chunks into one bytes value..iter_chunks() returns bytes | memoryview chunks for streaming. Use it
when the consumer can accept chunks directly: it avoids joining the complete
response into one bytes value, and static template fragments can be yielded
without copying them into that joined value. See the
render and compression experiment for
measured time and Python heap trade-offs..to_string(encoding="utf-8", errors="strict") joins and decodes the chunks
as text. Use it for CLI output, tests, and debugging; web responses usually
need bytes instead.| Library | Mean time | Renders per second | Indentation details |
|---|---|---|---|
| Fstache | 0.246 ms | 4066.5 | Deviates: ignore_indents=True skips standard standalone partial reindentation. |
| Fstache | 0.322 ms | 3102.2 | Follows standard standalone partial indentation. |
| mstache | 0.747 ms | 1339.0 | Deviates: keep_lines=True keeps tag-only lines instead of collapsing them, so partial indentation is not reapplied to every partial line. |
| mstache | 1.015 ms | 985.3 | Follows standard standalone partial indentation. |
| Chevron | 1.051 ms | 951.1 | Follows standard standalone partial indentation. |
| Pystache | 1.113 ms | 898.8 | Follows standard standalone partial indentation. |
| Field | Value |
|---|---|
| Python | CPython 3.14.6 |
| OS | Fedora Linux 44 (Workstation Edition), Linux 7.0.12-201.fc44.x86_64 |
| CPU | AMD Ryzen 7 8845HS w/ Radeon 780M Graphics, 8 cores / 16 threads |
| Compared versions | Fstache 0.1.1, Chevron 0.14.0, mstache 0.3.0, Pystache 0.6.8 |
| Command | RENDERER=<renderer> uv run --python 3.14 --extra dev python tests/perf_test.py<renderer> values: fstache.no_indentation, fstache, mstache.no_indentation, mstache, chevron, pystache |
On the same workstation, Fstache 0.1.7 rendered a 91,094-byte HTML response
with ignore_indents=True. Each timing includes both rendering and compression.
The uncompressed render took 0.252 ms (3972.1 renders per second).
| Compressor | Whole response | Continuous chunks | Chunk time cost | Response size | Peak traced heap, whole → chunks |
|---|---|---|---|---|---|
| Zstandard, level 3 | 0.336 ms | 0.446 ms | +32.7% | 12.7 KiB (-85.7%) | 276.14 → 155.83 KiB (-43.6%) |
| Brotli, text mode, quality 4 | 0.714 ms | 0.819 ms | +14.7% | 12.0 KiB (-86.6%) | 198.77 → 27.26 KiB (-86.3%) |
| gzip, level 6 | 1.115 ms | 1.327 ms | +19.0% | 12.6 KiB (-85.8%) | 395.82 → 321.15 KiB (-18.9%) |
In this run, Zstandard was the fastest compressor and Brotli produced the
smallest response. Passing RenderedTemplate.iter_chunks() through one
continuous compressor used less traced Python heap, but was slower for every
compressor. The heap figures come from tracemalloc and exclude allocations
inside native compression libraries.
The chunk variants used one compressor context, finalized it once, and did not
call to_bytes(), join the response, or flush between Fstache chunks. Across
905 input chunks, Brotli and Zstandard emitted no bytes before finalization;
gzip emitted only its 10-byte header. Real HTTP latency therefore depends on an
application's flush policy and any server or proxy buffering.
The run used Brotli 1.2.0, Zstandard 0.25.0, and zlib-ng 1.3.1 on CPython 3.14.6.
$4/month VPS throughput| Library | Mean time | Renders per second | Indentation and validation details |
|---|---|---|---|
| Fstache | 1.132 ms | 883.1 | Deviates: ignore_indents=True skips standard standalone partial reindentation. Baseline warning: apostrophes are escaped as '. |
| Fstache | 1.437 ms | 696.0 | Follows standard standalone partial indentation. Baseline warning: apostrophes are escaped as '. |
| mstache | 2.843 ms | 351.7 | Deviates: keep_lines=True keeps tag-only lines instead of collapsing them, so partial indentation is not reapplied to every partial line. Baseline warning: backticks are escaped as `. |
| mstache | 4.197 ms | 238.3 | Follows standard standalone partial indentation. Baseline warning: backticks are escaped as `. |
| Chevron | 4.476 ms | 223.4 | Follows standard standalone partial indentation. Baseline check passed. |
| Pystache | 5.081 ms | 196.8 | Follows standard standalone partial indentation. Baseline warning: apostrophes are escaped as '. |
| Field | Value |
|---|---|
| Python | CPython 3.14.6 |
| OS | Ubuntu 24.04.4 LTS, Linux 6.8.0-71-generic |
| CPU | DO-Regular, 1 core / 1 thread |
| RAM | 458 MiB, no swap |
| Compared versions | Fstache 0.1.2 from PyPI, Chevron 0.14.0, mstache 0.3.0, Pystache 0.6.8 |
| Assets | GitHub checkout at commit 489d8d9; only demo/ and tests/perf_test.py were used from the checkout. |
| Command | RENDERER=<renderer> uv run --no-project --python 3.14 --with fstache==0.1.2 --with chevron==0.14.0 --with mstache==0.3.0 --with pystache==0.6.8 python <checkout>/tests/perf_test.py<renderer> values: fstache.no_indentation, fstache, mstache.no_indentation, mstache, chevron, pystache |
node partial rendering.| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | djangofmt - A fast, HTML aware, Django template formatter, written in Rust | 0 | 10 | 21-02-2026 |
| 2 | fastjsondiff - Fastest JSON Diff Library | 0 | 10 | 18-01-2026 |
| 3 | tryke: Rust-Based, Jest-Style Test Runner | 0 | 50 | 07-05-2026 |
| 4 | rendercv - CV/resume generator, from YAML to PDF | 0 | 10 | 11-01-2026 |
| 5 | StegoForge: Zero-Dependency Python Steganography | 0 | 35 | 07-05-2026 |
| 6 | miniword: A WYSIWYG Word Processor in Python | 0 | 10 | 07-05-2026 |
| 7 | pyStrich: 1D and 2D Barcode Generator Library | 0 | 10 | 18-07-2026 |
| 8 | bedivierre/text-parser | 0 | 35 | 03-08-2026 |
| 9 | django-orjson - orjson, a Rusty replacement for json | 0 | 21.11 | 19-07-2026 |
| 10 | The Fastest Python Struct? | 0 | 10 | 27-06-2026 |