Skip to main content

htmpl

Type-safe HTML templating for Python 3.14+ using PEP 750 template strings.

from htmpl import html
from htmpl.elements import div, h1, p, ul, li

async def greeting(name: str, items: list[str]):
    return div(
        h1(f"Hello, {name}!"),
        t"<p>You have <strong>{len(items)}</strong> items:</p>",
        ul([li(item) for item in items]),
        class_="card",
    )

Why htmpl?

  • Type-safe — Components are functions with typed parameters. Your IDE catches errors.
  • No new syntax — It's just Python. No {% %} or {{ }} to learn.
  • Async-native — Coroutines resolve automatically at render time. No await spam.
  • Composable — Mix element factories and t-strings freely.
  • XSS-safe — All interpolations escaped by default.
  • Fast — Cache components with @cached, @cached_lru, or @cached_ttl.

Installation

pip install htmpl              # Core only
pip install htmpl[all]         # Everything

Requires Python 3.14+.

Quick Start

Elements

Build HTML with function calls:

from htmpl.elements import div, h1, p, a, ul, li, button

# Simple element
div("Hello")  # <div>Hello</div>

# With attributes
div("Hello", class_="greeting", id="main")  # <div class="greeting" id="main">Hello</div>

# Nested
div(
    h1("Title"),
    p("Some text"),
    a("Click me", href="/page"),
)

# Lists flatten automatically
ul([li(item) for item in ["one", "two", "three"]])

T-strings

Use Python 3.14 template strings for inline HTML:

from htmpl import html

name = "World"
await html(t"<h1>Hello, {name}!</h1>")

Values are escaped automatically:

user_input = "<script>alert('xss')</script>"
await html(t"<p>{user_input}</p>")
# <p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>

Mix Both

Elements and t-strings compose freely:

def UserCard(user: User):
    return article(
        t"<header>{user.name}</header>",
        p(user.bio),
        div(
            [Badge(role) for role in user.roles],
            class_="badges",
        ),
    )

Async Components

Async functions just work—coroutines resolve at render time:

async def UserProfile(user_id: int):
    user = await get_user(user_id)  # DB call
    posts = await get_posts(user_id)

    return section(
        h1(user.name),
        PostList(posts),  # Can be sync or async
    )

# No awaits needed when composing
def Dashboard(user_id: int):
    return div(
        UserProfile(user_id),  # Coroutine, resolved at render
        Sidebar(),
    )

Conditional Rendering

Return None to render nothing:

def AdminBadge(user: User):
    if not user.is_admin:
        return None
    return span("Admin", class_="badge")

# Renders badge only for admins
div(
    h1(user.name),
    AdminBadge(user),  # None disappears cleanly
)

Caching

from htmpl import cached, cached_lru, cached_ttl

@cached  # Forever
async def Footer():
    return footer(t"<p>© 2025</p>")

@cached_lru(maxsize=100)  # LRU eviction
async def UserBadge(role: str):
    return span(role, class_="badge")

@cached_ttl(seconds=60)  # Expires after 60s
async def GlobalStats():
    stats = await fetch_stats()
    return div(t"<strong>{stats.users}</strong> users")

FastAPI Integration

from fastapi import FastAPI
from htmpl.fastapi import Router
from htmpl.elements import section, h1, p

app = FastAPI()
router = Router()

@router.get("/")
async def home():
    return section(h1("Welcome"), p("Just return Elements."))

@router.get("/user/{name}")
async def user(name: str):
    return section(h1(f"Hello, {name}!"))

app.include_router(router)

The Router automatically converts Element, Fragment, SafeHTML, and Template returns to HTML responses.

HTMX Integration

from htmpl.htmx import HX, SearchInput, LazyLoad

# HX attribute builder
hx = HX(post="/api/save", target="#result", swap="innerHTML")
button("Save", **{str(k): v for k, v in hx})

# Or use built-in patterns
SearchInput("q", src="/search", target="#results", debounce=300)
LazyLoad("/api/content", placeholder=div("Loading...", aria_busy="true"))

Forms

Render Pydantic models as forms with automatic HTML5 validation:

from pydantic import BaseModel, Field, EmailStr
from htmpl.fastapi import Router
from htmpl.elements import section, h1

router = Router()

class LoginSchema(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8)

@router.form("/login", LoginSchema, submit_text="Sign In")
async def login(data: LoginSchema):
    # Only called if validation passes
    user = await authenticate(data.email, data.password)
    return section(h1(f"Welcome, {user.name}!"))

The @router.form decorator:

  • GET /login → Renders the form
  • POST /login → Validates, re-renders with errors or calls your handler

Generates proper HTML5 validation attributes:

<input type="email" name="email" required />
<input type="password" name="password" required minlength="8" />

Custom Form Layouts

Use the template parameter for full control:

from htmpl.elements import article, h2, form, div, button

def login_template(renderer, values, errors):
    return article(
        h2("Sign In"),
        form(
            renderer.inline("email", "password", values=values, errors=errors),
            button("Sign In", type="submit"),
            action="/login",
        ),
    )

@router.form("/login", LoginSchema, template=login_template)
async def login(data: LoginSchema):
    user = await authenticate(data.email, data.password)
    return section(h1(f"Welcome, {user.name}!"))

Or use FormRenderer directly for maximum flexibility:

from htmpl.forms import FormRenderer

renderer = FormRenderer(SignupSchema)

form(
    renderer.inline("first_name", "last_name", values=values),
    renderer.group("Contact", "email", "phone", values=values, errors=errors),

    # Manual control
    div(
        renderer.label_for("password"),
        renderer.input("password", class_="custom"),
        renderer.error_for("password", errors),
    ),

    button("Submit", type="submit"),
    action="/submit",
)

Components Library

Built-in Pico CSS components:

from htmpl import Document, Page, Nav, Card, Form, Field, Button, Alert, Modal, Table, Grid

Page(
    "My App",
    nav=Nav("Brand", [("Home", "/"), ("About", "/about")]),
    children=section(
        Card(
            p("Card content"),
            title="Card Title",
        ),
    ),
)

API Reference

Core

Function Description
html(template) Process a t-string into SafeHTML
raw(str) Mark string as safe (no escaping)
attr(name, value) Build a safe HTML attribute
SafeHTML Wrapper for pre-escaped content

Elements

All standard HTML elements as functions:

from htmpl.elements import (
    # Layout
    div, span, section, article, header, footer, nav, main, aside,
    # Text
    h1, h2, h3, h4, h5, h6, p, a, strong, em, code, pre,
    # Lists
    ul, ol, li,
    # Tables
    table, thead, tbody, tr, th, td,
    # Forms
    form, label, input_, button, select, option, textarea,
    # Media
    img, video, audio,
    # Other
    br, hr, fragment,
)

Attributes use _ suffix for Python keywords: class_, for_, type_.

Forms

Method Description
render() Full form with all fields
render_field(name) Single field with label
input(name) Just the input element
label_for(name) Just the label
error_for(name, errors) Error message if present
fields(*names) Multiple fields as list
inline(*names) Fields in grid row
group(title, *names) Fields in fieldset

Comparison

Feature htmpl Jinja React
Type safety ✅ ❌ ✅ (TSX)
Python-native ✅ ❌ ❌
Async support ✅ ❌ ❌
No build step ✅ ✅ ❌
IDE support ✅ Limited ✅
Learning curve Low Medium High

License

MIT

Metadata

Release files for htmpl 0.4.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for htmpl 0.4.3
File Size Uploaded
htmpl-0.4.3.tar.gz 76.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for htmpl 0.4.3
File Interpreter ABI Platform
htmpl-0.4.3-py3-none-any.whl Python 3 none any Details

Total release size: 97.1 kB

Release files / htmpl-0.4.3.tar.gz

Download URL htmpl-0.4.3.tar.gz
Size 76.8 kB
Tags Source
SHA-256 checksum
How to use checksums
a7819d2d929b163b406f17fc5012e6cd97f1b7200391b26e8a6c07362f5666b5
BLAKE2b-256 checksum
How to use checksums
695502cdef2693ba31e8ea228f907e181cb1946a5b7530bf6b6f508c56ed74ba
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.16 {"installer":{"name":"uv","version":"0.9.16","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":null}

Release files / htmpl-0.4.3-py3-none-any.whl

Download URL htmpl-0.4.3-py3-none-any.whl
Size 20.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
12e12a35bbf53807721a53fcc3b8208dc87ab807d71c75583fa681046650c7a3
BLAKE2b-256 checksum
How to use checksums
05d7bd595db5dcfc78618e39c258134f32ca5d293786c34dfe94edf3362ef9f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.16 {"installer":{"name":"uv","version":"0.9.16","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":null}

Release history Release notifications | RSS feed

This release

0.4.3 This release

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page