Fast dependency-free, typed Mustache renderer for Python 3.12+
Project description
Fstache
Author: vkorobkov
Overview
- Dependency-free Mustache renderer for Python 3.12+.
- Supports upstream Mustache spec fixtures, including lambdas and dynamic partial names; inheritance is unsupported.
- Built for speed; with standard partial indentation respected, benchmarked at 3.3x Chevron, 3.1x mstache, and 3.5x Pystache.
- PEP 561 typed package.
- Supports Python mappings, objects, sequences, and callables as render data.
- Static partials are preloaded and inlined, eliminating render-time boundary overhead.
Why Fstache Exists
Fstache 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.
Installation
pip install fstache
Package page: fstache on PyPI
Quick Start
Create templates/hello.mustache:
Hello, {{name}}!
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:
Hello, Ada!
Recommended Setup
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.
API At A Glance
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.
Usage
Template Preloading
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.
Partials
Partial 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"})
Compact Output
By default, standalone partials inherit the indentation before the partial tag. For example, this template:
Begin.
{{> name.mustache}}
End.
with name.mustache:
one
{{name}}
renders as:
Begin.
one
Ada
End.
Set ignore_indents=True to skip that standalone partial indentation:
render = fstache.create_renderer("./templates", ignore_indents=True)
Begin.
one
Ada
End.
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:
Begin.
one
Ada
End.
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.
Template Extensions
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.
Extensionless Names
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)
Missing Variables
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,
)
Missing Templates
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,
)
Custom Escaping and Delimiters
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:
Hello, [[name]]!
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 Render
Use 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().
Render Data
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:
strvalues are UTF-8 encoded.Noneand missing variables render as empty by default.- Other scalar values render with
str(value). bytesandmemoryviewvalues are rendered as byte chunks, avoiding string conversion and letting unescaped streaming output reuse the original bytes.- A Fstache render result can be passed as a value to embed one rendered fragment in another without joining it first.
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.
Output
Renderer calls return a RenderedTemplate:
.to_bytes()joins the rendered chunks into onebytesvalue..iter_chunks()returnsbytes | memoryviewchunks for streaming. Prefer this for best performance when possible: it avoids buffering the whole response into one value, and static template fragments can be yielded from the compiled template without copying them into a joined response first..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.
Benchmarks
Workstation throughput
| 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. |
Workstation environment
| 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 |
$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 '. |
$4/month VPS environment
| 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 |
Methodology
- The benchmark renders a realistic, heavy marketing/docs HTML page from the
source Mustache templates, JSON data, and
perf script:
- About 100 KiB of rendered HTML (source) from 15 Mustache templates, including 14 partial files.
- Tailwind CSS v4 utility-heavy markup with Alpine.js attributes, inline SVG icons, responsive navigation, cards, tables, accordions, and form controls.
- About 15 KiB of JSON context data with nested arrays for navigation, feature cards, testimonials, a recursive docs tree, comparison rows, blog posts, changelog entries, FAQs, and pricing plans.
- 42 section, inverted-section, and partial references, including 24 section
tags for loops and conditionals plus recursive
nodepartial rendering.
- The benchmark isolates render throughput from setup work:
- Each engine preloads the template files and partials during setup.
- Each engine uses the closest available precompiled, preparsed, or pretokenized representation for the layout and partials.
- The timed render loop excludes disk I/O and one-time template preparation.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fstache-0.1.3.tar.gz.
File metadata
- Download URL: fstache-0.1.3.tar.gz
- Upload date:
- Size: 21.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59298de5def49faed884225bb7680ef3ebe901f7adf047e29d1bb7a7a637aade
|
|
| MD5 |
28131c1683d202904704c9a4832121c1
|
|
| BLAKE2b-256 |
73352ecb9995b45b990f82b7f163aa67ff9280943ad38bf7dab61831b5fc64dc
|
File details
Details for the file fstache-0.1.3-py3-none-any.whl.
File metadata
- Download URL: fstache-0.1.3-py3-none-any.whl
- Upload date:
- Size: 24.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
caba1e306658136b34e572646a0a9e20df3cac0fd782305b36af184fbc16793d
|
|
| MD5 |
174800cfe451b31e953099ed330ce35a
|
|
| BLAKE2b-256 |
91b99c8792b41fb472478916ac7dfb1cf546ad1f8554ece7871ee800bb73ce0c
|