Skip to main content

Fast dependency-free, typed Mustache renderer for Python 3.12+

Project description

Fstache

PyPI version Python versions CI Publish package License Typed: PEP 561

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.

Examples

  • Flask example shows Fstache with regular and streamed WSGI Response objects in a standalone uv project.
  • Starlette example shows Fstache with HTMLResponse and StreamingResponse in a standalone uv project.

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.

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.

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"&#91;")
        .replace(b"]", b"&#93;")
    )

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:

  • str values are UTF-8 encoded.
  • None and missing variables render as empty by default.
  • Other scalar values render with str(value).
  • bytes and memoryview values 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 one bytes value.
  • .iter_chunks() returns bytes | memoryview chunks 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 &#x27;.
Fstache 1.437 ms 696.0 Follows standard standalone partial indentation. Baseline warning: apostrophes are escaped as &#x27;.
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 &#x60;.
mstache 4.197 ms 238.3 Follows standard standalone partial indentation. Baseline warning: backticks are escaped as &#x60;.
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 &#x27;.

$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 node partial 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

fstache-0.1.4.tar.gz (22.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fstache-0.1.4-py3-none-any.whl (24.8 kB view details)

Uploaded Python 3

File details

Details for the file fstache-0.1.4.tar.gz.

File metadata

  • Download URL: fstache-0.1.4.tar.gz
  • Upload date:
  • Size: 22.1 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

Hashes for fstache-0.1.4.tar.gz
Algorithm Hash digest
SHA256 53b6d27402167e786a3efba28db636397d49629573db2bfb7d4f11948c0b9285
MD5 5f45c4c3bd264c5c6325e9e06540e909
BLAKE2b-256 b72e6fb9c03a5ab5b00f0f7f5d72fb73f9a2a602c44f07a1f500576bf32096aa

See more details on using hashes here.

File details

Details for the file fstache-0.1.4-py3-none-any.whl.

File metadata

  • Download URL: fstache-0.1.4-py3-none-any.whl
  • Upload date:
  • Size: 24.8 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

Hashes for fstache-0.1.4-py3-none-any.whl
Algorithm Hash digest
SHA256 5550b95344f9d05231c0c8aca892af4a9f60f8f0e98471b75ff70cc9909fda30
MD5 efab5906fc258d98273d574d13bf3d44
BLAKE2b-256 ac9cc5d9f337cb183d9e267df5b9769c6e4a290852f20fdbe18d40828cb3f910

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page