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
Templateobject instead of astr, 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.frozendictandsentinel(3.15) are new built-ins that replaceMappingProxyTypewrappers and theobject()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, <script>alert(1)</script>!</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. Callingstr(template)does not give you formatted output, so you always need a processing function. - Conversions (
!r,!s) and format specs are recorded on theInterpolation. Your function decides whether to apply them. - Type-annotate processors as
Template -> T. They can return any type, not juststr.
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 useas. finallysafety:return,break, orcontinueinside afinallyblock now emits a SyntaxWarning.- Free-threaded builds: the no-GIL build is officially supported, though still optional.
- Subinterpreters: the
concurrent.interpretersmodule andInterpreterPoolExecutorgive you isolated interpreters in one process. - Tail-calling interpreter: a new bytecode dispatch strategy that needs a recent Clang.
- Remote debugging:
sys.remote_exec()andpython -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
tryblocks. - The interpreter flag
-X lazy_imports=allmakes 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 importline.
| 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%andtottimecount samples where the function ran its own code. A high value marks a true hotspot.cumul%andcumtimeinclude time spent in callees. A highcumul%with a lowsample%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:
AttributeErrornow suggests nested paths (Did you mean '.customer.email'?) and maps JavaScript-style names, solist.pushpoints you to.append. math.integer: integer-only helpers get their own namespace.re.prefixmatch(): an explicit name for whatre.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.@contextmanageron 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:
- File encoding: audit every
open()call withoutencoding=. Pass the encoding explicitly so behavior is identical on 3.14 and 3.15. - Annotation introspection: replace direct
__annotations__reads withannotationlib.get_annotations(). sqlite3.connect(): every parameter after the database path is now keyword-only.strptimewithout a year: parsing a day of month with no year now raisesValueError.- Removed modules:
sre_compile,sre_constants, andsre_parseare gone, and theprofilemodule is deprecated. - 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.




