What’s New in Python 3.14 and 3.15: t-strings, Lazy Imports, and More

Python 3.14 shipped template strings and deferred annotations. Python 3.15 followed with explicit lazy imports, two new built-in types, and a sampling profiler in the standard library. Together they change how you write string-handling code, start CLI tools, and find performance bottlenecks. This guide covers both releases with working code and the migration traps that break real projects.

Release status: Python 3.14 was released in October 2025. Python 3.15 reached its final release candidate, and 3.15.0 was scheduled for October 1, 2026 under PEP 790. Check python.org for the current patch version before you pin it in production.

Quick Takeaways

  • t-strings (3.14) create a Template object instead of a str, so you can sanitize interpolated values before they become output.
  • lazy import (3.15) defers module loading until first use, which cuts startup time for CLI tools.
  • frozendict and sentinel (3.15) are new built-ins that replace MappingProxyType wrappers and the object() sentinel idiom.
  • UTF-8 by default (3.15) ends locale-dependent open() behavior, but it can break code that reads legacy-encoded files.
Feature Version Category Migration Risk
Template strings (t"...") 3.14 Syntax Low (additive)
Deferred annotations 3.14 Semantics Medium (introspection code)
compression.zstd 3.14 Stdlib Low
except A, B: without parentheses 3.14 Syntax Low
Free-threaded build officially supported 3.14 Runtime Low (opt-in)
lazy import 3.15 Syntax Low (opt-in)
Unpacking in comprehensions 3.15 Syntax Low
frozendict, sentinel 3.15 Built-ins Low
profiling.sampling (Tachyon) 3.15 Tooling Low
UTF-8 default encoding 3.15 Runtime High (legacy files)

Python 3.14: The Features That Matter

Template Strings (t-strings)

An f-string evaluates immediately and returns a plain str. That is the root of most injection bugs: by the time you hold the string, the untrusted value is already fused into it. A t-string (PEP 750) keeps the static text and the interpolated values separate, so your code decides how to combine them.

Syntax Breakdown

Prefix a string with t instead of f. The result is a string.templatelib.Template. Iterating it yields plain strings and Interpolation objects. Each Interpolation exposes .value, .expression, .conversion, and .format_spec.

from string.templatelib import Template, Interpolation

name = "Ada"
template = t"Hello, {name}!"

# A Template is NOT a str. It holds parts, not a rendered result.
print(type(template).__name__)

for part in template:
    kind = "interpolation" if isinstance(part, Interpolation) else "static"
    print(kind, repr(part if kind == "static" else part.value))

This prints Template, then static 'Hello, ', interpolation 'Ada', and static '!'. Static text and dynamic values stay separate until your own function joins them.

Practical Implementation: Safe HTML

from html import escape
from string.templatelib import Template, Interpolation

def html(template: Template) -> str:
    """Render a Template, escaping every interpolated value."""
    parts: list[str] = []
    for item in template:
        if isinstance(item, Interpolation):
            parts.append(escape(str(item.value)))  # sanitize dynamic data
        else:
            parts.append(item)                     # trusted static markup
    return "".join(parts)

comment = "<script>alert(1)</script>"
print(html(t"<p>Hello, {comment}!</p>"))

Output: <p>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</p>. The template author’s markup passes through untouched. The user-supplied value is escaped. The function runs in O(n) over the number of parts.

Practical Implementation: Parameterized SQL

from string.templatelib import Template, Interpolation

def sql(template: Template) -> tuple[str, list[object]]:
    """Convert a Template into a query string plus bound parameters."""
    query: list[str] = []
    params: list[object] = []
    for item in template:
        if isinstance(item, Interpolation):
            query.append("?")             # placeholder, never the raw value
            params.append(item.value)
        else:
            query.append(item)
    return "".join(query), params

user_id = "1 OR 1=1"
print(sql(t"SELECT * FROM users WHERE id = {user_id}"))

Output: ('SELECT * FROM users WHERE id = ?', ['1 OR 1=1']). The hostile input becomes a bound parameter and never reaches the SQL text. Pass both values to cursor.execute(query, params).

Edge Cases

  • A Template has no __str__ that renders it. Calling str(template) does not give you formatted output, so you always need a processing function.
  • Conversions (!r, !s) and format specs are recorded on the Interpolation. Your function decides whether to apply them.
  • Type-annotate processors as Template -> T. They can return any type, not just str.

Deferred Evaluation of Annotations

Python 3.14 stops evaluating annotations when a function or class is defined (PEP 649 and PEP 749). Forward references now work without quotes or from __future__ import annotations.

class Node:
    # Node is not fully defined yet, but no quotes are needed in 3.14+
    def link(self, other: Node) -> Node:
        return other

The new annotationlib module exposes annotations in several formats, including a FORWARDREF format that survives names that do not exist yet. Libraries that read __annotations__ directly, such as serializers and dependency-injection containers, should switch to annotationlib.get_annotations().

Other 3.14 Additions

from compression import zstd

data = b"payload" * 1000
packed = zstd.compress(data)            # Zstandard, now in the stdlib
assert zstd.decompress(packed) == data
print(len(data), "->", len(packed))

The assertion passes, and the repeated payload compresses to a small fraction of its original size. Beyond compression.zstd, 3.14 delivered:

  • Parenthesis-free except: except TimeoutError, ConnectionError: is valid when you do not use as.
  • finally safety: return, break, or continue inside a finally block now emits a SyntaxWarning.
  • Free-threaded builds: the no-GIL build is officially supported, though still optional.
  • Subinterpreters: the concurrent.interpreters module and InterpreterPoolExecutor give you isolated interpreters in one process.
  • Tail-calling interpreter: a new bytecode dispatch strategy that needs a recent Clang.
  • Remote debugging: sys.remote_exec() and python -m pdb -p <pid> attach to running processes.

Garbage collector note: 3.14 introduced an incremental GC, but memory regressions pushed the core team to revert to the generational collector starting with 3.14.5. Re-measure memory if you tuned for the early 3.14 behavior.

Python 3.15: The Features That Matter

Lazy Imports

Every top-level import runs before your first line of logic. For a CLI that prints --help and exits, that cost is pure waste. PEP 810 adds the lazy soft keyword, which binds the name now and loads the module on first use.

import sys

lazy import json  # name bound, module NOT loaded yet

print("json loaded:", "json" in sys.modules)
config = json.dumps({"theme": "dark", "autosave": True})  # first use triggers load
print("json loaded:", "json" in sys.modules)
print(config)

Output: json loaded: False, then json loaded: True, then {"theme": "dark", "autosave": true}.

The from form works too: lazy from pathlib import Path. Four rules to remember:

  • Lazy imports are allowed only at module level, not inside functions, classes, or try blocks.
  • The interpreter flag -X lazy_imports=all makes every import lazy without editing source.
  • Lazy imports move cost instead of removing it. A program that touches every dependency pays the same total price.
  • A typo in a lazy import fails at first use, not at startup. The traceback points to both the use site and the lazy import line.
Approach Imports at Top of File Startup Cost Error Timing Scope
Regular import Yes Highest At startup Anywhere
Import inside function No Lowest At call time Function
lazy import Yes Low At first use Module level only

Unpacking in Comprehensions

PEP 798 allows * and ** as the top-level expression of a comprehension.

daily_temps = [[18.2, 21.7], [17.9], [19.4, 23.1]]
flat = [*temps for temps in daily_temps]
print(flat)  # [18.2, 21.7, 17.9, 19.4, 23.1]

layers = [{"host": "localhost", "port": 8000}, {"port": 5432}, {"debug": True}]
merged = {**layer for layer in layers}
print(merged)  # {'host': 'localhost', 'port': 5432, 'debug': True}

Later keys win, matching the | operator, so this is a clean fit for layered configuration. Generator expressions work too: sum(*temps for temps in daily_temps) flattens without building an intermediate list.

frozendict

Python finally has an immutable mapping built in. frozendict supports | merging, returns a new object on every change, and is hashable when its values are.

defaults = frozendict({"theme": "light", "autosave": True})
custom = defaults | {"theme": "dark"}
print(custom)  # frozendict({'theme': 'dark', 'autosave': True})

try:
    custom["theme"] = "sepia"
except TypeError as exc:
    print(exc)  # 'frozendict' object does not support item assignment

A hashable frozendict can serve as a dictionary key or as an argument to a function decorated with @lru_cache. It does not subclass dict, so isinstance(x, dict) returns False. Check for collections.abc.Mapping instead.

Feature dict types.MappingProxyType frozendict
Mutable Yes No (view only) No
Hashable No No Yes (if values are)
Backing data can change Yes Yes, via original No
Merge with | Yes No Yes, returns frozendict
isinstance(x, dict) True False False

sentinel

When None is a legitimate value, you need a distinct “nothing was passed” marker. The old idiom, a module-level object(), prints as a memory address and loses identity when pickled. The sentinel() built-in fixes both problems.

MISSING = sentinel("MISSING")
print(MISSING)  # MISSING

def get_option(options: dict, name: str, default=MISSING):
    value = options.get(name, default)
    if value is MISSING:
        raise KeyError(name)
    return value

print(get_option({"retries": None}, "retries"))      # None (a real value)
print(get_option({}, "timeout", default=None))       # None (explicit default)
# get_option({}, "timeout") raises KeyError: 'timeout'

Sentinels are truthy, compare equal only to themselves, and survive copy and pickle when defined at module level under a matching name. Type checkers narrow them with is checks.

The Tachyon Sampling Profiler

cProfile hooks every function call, which adds overhead. The new profiling.sampling module, called Tachyon, samples the target’s call stack from outside the process, 1,000 times per second by default.

python -m profiling.sampling run wordstats.py

Read the report columns carefully:

  • sample% and tottime count samples where the function ran its own code. A high value marks a true hotspot.
  • cumul% and cumtime include time spent in callees. A high cumul% with a low sample% marks a caller, not the slow code.

Tachyon can attach to a running process, show a live top-style view with --live, and export flame graphs. The tracing profiler now lives at profiling.tracing, with cProfile kept as an alias. The pure-Python profile module is deprecated and slated for removal in 3.17.

UTF-8 as the Default Encoding

PEP 686 makes UTF-8 Mode the default on every platform. Previously, open("menu.txt") used the system locale, which silently garbled text on machines with a legacy code page.

import locale

print(locale.getpreferredencoding())      # 'utf-8' in 3.15, on any locale
print(open("menu.txt").read())            # Crème brûlée — 7 €

To restore the old behavior for one call, pass encoding="locale". For the whole interpreter, set PYTHONUTF8=0. The change does not convert files already on disk. A file saved as cp1252 will now raise UnicodeDecodeError unless you pass encoding="cp1252" or convert it once.

Smaller 3.15 Changes Worth Knowing

  • Better errors: AttributeError now suggests nested paths (Did you mean '.customer.email'?) and maps JavaScript-style names, so list.push points you to .append.
  • math.integer: integer-only helpers get their own namespace.
  • re.prefixmatch(): an explicit name for what re.match() has always done.
  • json.loads(..., array_hook=tuple): deserialize arrays into any type.
  • bytearray.take_bytes(n): remove and return bytes from the buffer front without a copy.
  • @contextmanager on generators: the context now stays open for the generator’s lifetime, which changes behavior for code that relied on the old early exit.
  • TypeForm and closed TypedDict: PEP 747 and PEP 728 give type checkers precise descriptions of type expressions and exact dictionary shapes.
  • JIT upgrade: the experimental JIT is faster, with the core team reporting single-digit to low-double-digit geometric mean gains on supported platforms. It remains off by default, so benchmark before you enable it with PYTHON_JIT=1.

Bad Code vs. Good Code

Anti-pattern 1: Building SQL with f-strings

# BAD: untrusted input is fused into the query before you can inspect it
user_id = request.args["id"]
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
# GOOD (3.14+): the processor binds parameters, so values never touch SQL text
query, params = sql(t"SELECT * FROM users WHERE id = {user_id}")
cursor.execute(query, params)

Anti-pattern 2: Scattered imports and object() sentinels

# BAD: imports hidden in functions, and an opaque sentinel
_MISSING = object()  # repr: <object object at 0x7f...>

def export(data, indent=_MISSING):
    import json  # buried dependency
    return json.dumps(data) if indent is _MISSING else json.dumps(data, indent=indent)
# GOOD (3.15): visible dependency, readable and pickle-safe sentinel
lazy import json

MISSING = sentinel("MISSING")

def export(data, indent=MISSING):
    return json.dumps(data) if indent is MISSING else json.dumps(data, indent=indent)

Anti-pattern 3: Merging dicts with loops

# BAD: manual accumulation
merged = {}
for layer in layers:
    merged.update(layer)
# GOOD (3.15): one expression, same last-key-wins semantics
merged = {**layer for layer in layers}

Upgrade Checklist

Test on 3.15 with warnings promoted to errors before you deploy:

uv run --isolated --python 3.15 pytest -W error::DeprecationWarning

Check these first:

  1. File encoding: audit every open() call without encoding=. Pass the encoding explicitly so behavior is identical on 3.14 and 3.15.
  2. Annotation introspection: replace direct __annotations__ reads with annotationlib.get_annotations().
  3. sqlite3.connect(): every parameter after the database path is now keyword-only.
  4. strptime without a year: parsing a day of month with no year now raises ValueError.
  5. Removed modules: sre_compile, sre_constants, and sre_parse are gone, and the profile module is deprecated.
  6. Compiled dependencies: wait for wheels targeting the new version. For production servers, waiting for 3.15.1 remains the conservative choice.

FAQ

What is a t-string in Python 3.14?

A t-string is a string literal prefixed with t that produces a Template object instead of a str. The template stores static text and interpolated values separately, so a function can escape, validate, or bind each value before building the final output. It is the safe alternative to f-strings for HTML, SQL, and shell commands.

How do lazy imports work in Python 3.15?

Writing lazy import module binds the name without loading the module. Python loads it the first time you access the name. This shortens startup for short-lived programs such as CLI tools. Lazy imports work only at module level, and import errors surface at first use rather than at startup.

Is Python 3.15 faster than Python 3.14?

Often, but it depends on your platform and build. The experimental JIT is faster than before but stays off by default. Windows builds gain the tail-calling interpreter, and lazy imports reduce startup time for CLI tools. Benchmark your own workload before you enable the JIT.

Should you use frozendict instead of a regular dict?

Use frozendict for configuration, cache keys, and shared constants that must not change. It is hashable when its values are, supports | merging, and cannot be mutated through a hidden reference. Keep dict for data you build up incrementally. Remember that frozendict is not a dict subclass, so use Mapping for type checks.

Hot this week

The State of Robotics in 2026: 10 Biggest Developments

The 10 biggest robotics developments of 2026: whole-body VLA models, humanoid safety, ROS 2 Lyrical Luth, and Jetson Thor. Get the data and code.

EU Machinery Regulation 2027: What Robot Builders Need to Know

Building robots for the EU? Regulation (EU) 2023/1230 applies from 20 Jan 2027. Get the cybersecurity, AI, and CE marking checklist now.

ISO 10218:2025 Explained: The New Industrial Robot Safety Standard

ISO 10218:2025 rewrites industrial robot safety: Class I/II robots, built-in cobot limits, cybersecurity. Get the checklist and ROS2 code. Read now.

NVIDIA Jetson Orin Nano, AGX Orin, and Thor: Which One for Your Robot?

Jetson Orin Nano vs AGX Orin vs Thor: compare TOPS, memory bandwidth, power, and price to pick the right robot compute. Read the guide.

ROS 2 Distributions Explained: Humble, Jazzy, Kilted, and Lyrical (Which to Use)

Compare ROS 2 Humble, Jazzy, Kilted, and Lyrical by EOL date, platform support, and features. Pick the right distro for your robot. Read the guide.

Topics

The State of Robotics in 2026: 10 Biggest Developments

The 10 biggest robotics developments of 2026: whole-body VLA models, humanoid safety, ROS 2 Lyrical Luth, and Jetson Thor. Get the data and code.

EU Machinery Regulation 2027: What Robot Builders Need to Know

Building robots for the EU? Regulation (EU) 2023/1230 applies from 20 Jan 2027. Get the cybersecurity, AI, and CE marking checklist now.

ISO 10218:2025 Explained: The New Industrial Robot Safety Standard

ISO 10218:2025 rewrites industrial robot safety: Class I/II robots, built-in cobot limits, cybersecurity. Get the checklist and ROS2 code. Read now.

NVIDIA Jetson Orin Nano, AGX Orin, and Thor: Which One for Your Robot?

Jetson Orin Nano vs AGX Orin vs Thor: compare TOPS, memory bandwidth, power, and price to pick the right robot compute. Read the guide.

ROS 2 Distributions Explained: Humble, Jazzy, Kilted, and Lyrical (Which to Use)

Compare ROS 2 Humble, Jazzy, Kilted, and Lyrical by EOL date, platform support, and features. Pick the right distro for your robot. Read the guide.

Build a Low-Cost AI Robot Arm With SO-101 and LeRobot

Build an SO-101 robot arm under $250, calibrate it, record demos, and train an ACT policy with LeRobot. Follow the full guide and start building.

Robot Foundation Models: GR00T, pi, Gemini Robotics, and Open Alternatives Compared

Compare robot foundation models: NVIDIA GR00T, Physical Intelligence π, Gemini Robotics 2, and open VLAs. Get latency math, code, and a pick guide.

How Much Does a Humanoid Robot Cost? Prices, Subscriptions, and Hidden Costs

Humanoid robot cost in 2026: prices from $4,900, $499/mo subscriptions, and hidden fees. See the full TCO breakdown and compare models now.

Related Articles

Popular Categories