Thread Safety

Free-threading and concurrency support

2 min read 419 words

Patitas is designed for Python 3.14t free-threading.

Design Principles

  1. 1

    Immutable AST

    All AST nodes are frozen dataclasses:

    @dataclass(frozen=True, slots=True)
    class Heading:
        level: int
        children: tuple[Inline, ...]
    

    Frozen = no mutation = no race conditions.

  2. 2

    No shared state

    Parser and renderer instances don't share mutable state:

    # Each call creates fresh instances
    doc1 = parse(source1)  # Thread 1
    doc2 = parse(source2)  # Thread 2 - no conflict
    
  3. 3

    Thread-local configuration

    Parse configuration uses Python'sContextVar(thread-local by design):

    from patitas import Markdown
    
    # Each thread gets isolated configuration
    md1 = Markdown(plugins=["table"])  # Thread 1
    md2 = Markdown(plugins=["math"])    # Thread 2
    
    # Concurrent parsing with different configs - safe!
    html1 = md1("| a | b |")  # Uses tables config
    html2 = md2("$x^2$")      # Uses math config (no conflict)
    
  4. 4

    Thread-local extension points

    Global extension points use thread-local storage:

    # Safe for concurrent use
    set_highlighter(my_highlighter)
    

Concurrent Parsing

Parse multiple documents in parallel:

import concurrent.futures
from patitas import parse

documents = ["# Doc 1", "# Doc 2", "# Doc 3"]

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(parse, documents))

Concurrent Rendering

Render multiple ASTs in parallel:

from patitas import render

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    futures = [
        executor.submit(render, doc, source=src)
        for doc, src in zip(docs, sources)
    ]
    htmls = [f.result() for f in futures]

ContextVar Configuration

For advanced use cases, you can control configuration explicitly:

from patitas import (
    Parser,
    ParseConfig,
    parse_config_context,
    set_parse_config,
    reset_parse_config,
)

# Option 1: Context manager (recommended)
with parse_config_context(ParseConfig(tables_enabled=True)):
    parser = Parser(source)
    doc = parser.parse()
# Config automatically reset after context

# Option 2: Manual control
set_parse_config(ParseConfig(math_enabled=True))
try:
    parser = Parser(source)
    doc = parser.parse()
finally:
    reset_parse_config()

The Markdown class handles this automatically—you only need explicit control when using Parserdirectly.

Best Practices

Do

  • Create parser/renderer per thread or per call
  • Use frozen AST nodes (default)
  • Share source text (immutable strings are safe)
  • UseMarkdownclass for automatic config management

Don't

  • Mutate AST nodes (they're frozen anyway)
  • Share mutable registries across threads
  • Use global mutable state
  • Forget to reset config when usingset_parse_config()directly

Python 3.14t

Patitas is built for PEP 703 (free-threading):

# pyproject.toml
[tool.maturin]
module-name = "patitas._core"

# Declares GIL-free compatibility
# _Py_mod_gil = 0 (if there were C extensions)

Run with GIL disabled:

PYTHON_GIL=0 python -c "from patitas import parse; parse('# Test')"