I have a confession. When the Star Trek reboot dropped in 2009 and everyone started using "warp speed" as a metaphor for tech velocity, I rolled my eyes — not because I'm not a fan, but because I am. I grew up on TNG (Star Trek: The Next Generation — Picard's Enterprise, the holodeck, the best theme music in television history). I'm working through Klingon with the Klingon Language Institute, I have the Vulcan salute in muscle memory, and I own three Maine Coons including Purrcard my tuxedo boy, which I think qualifies me as a Data apologist. So when I say Python 3.15 feels like a freshly refit Enterprise — more capable, leaner, the crew the same but the ship better — I mean it with full nerd sincerity.
In my last post, I wrote about how AI has collapsed the old divisions in software engineering. This post gets specific. I've been building EventLoop — a Chicago events discovery chatbot, now live at eventloop.city. A Python backend that scrapes venue calendars, talks to Claude via PydanticAI, and serves a streaming SSE chat API to a TypeScript frontend. I built it on the Python 3.15 release candidates; final dropped the morning this went up. Here's what that looks like, where 3.15 shines, and why — if you're building AI pipelines in 2026 — it's the version to be on.
What Is EventLoop?
EventLoop answers questions like "what's happening in Logan Square this weekend?" with real answers — pulled from a PostgreSQL database of scraped Chicago venue events, optionally enriched by a live Google Events fallback via SerpAPI (which times out on roughly a third of calls at 90s — the local database answers first, so a dropped web search degrades rather than breaks the response). The chatbot uses Claude Sonnet 5.5 via PydanticAI, a Claude Haiku 4.5-powered intent classifier to gate the expensive agent, and a semantic embedding index (model2vec — no torch dependency, queries in ~0.3ms) for fast local retrieval.
Three services on Railway, one PostgreSQL database:
api— Python 3.15, FastAPI with Server-Sent Events, always-onfrontend— nginx serving a TypeScript/Vite UI, proxying/api/*to the backend at runtimescraper— Python 3.15, Playwright 1.63 + BeautifulSoup4, cron every six hours
The stack:
- Python 3.15 — API, scraper, all of it
- uv 0.12.24 for environment and dependency management
- FastAPI + Server-Sent Events for streaming chat
- PydanticAI for the agent layer
- Anthropic (Claude Sonnet 5.5 for chat, Claude Haiku 4.5 for intent classification)
- SQLAlchemy async + asyncpg for PostgreSQL
- Playwright + BeautifulSoup4 for scrapers that need JavaScript rendering
- model2vec for semantic search (no torch, no CUDA, no drama)
- ruff for linting — zero errors, every suppression documented
- Railway for deployment — three services, one PostgreSQL database, ~$10–12/month
uv: The Transporter Beam for Python Environments
Before the language features: uv, now at version 0.12.24, has listed Python 3.15 as supported since #18552 (merged March 18, 2026) — tracking every alpha, beta, and release candidate long before final dropped.
The pitch used to be "pip but fast." That undersells what it is now. uv is your Python installer, virtual environment manager, lockfile manager, script runner, and workspace tool in one binary with no system Python requirement. For EventLoop I have a single pyproject.toml with optional dependency groups:
[project]
requires-python = ">=3.15"
[project.optional-dependencies]
app = [
"fastapi>=0.115.0",
"pydantic-ai>=2.50.0",
"anthropic>=0.45.0",
"sqlalchemy[asyncio]>=2.0.0",
"asyncpg>=0.32.0",
...
]
parser = [
"playwright>=1.63.0",
"beautifulsoup4>=4.12.0",
"sqlalchemy[asyncio]>=2.0.0",
"asyncpg>=0.32.0",
...
]
test = ["pytest>=7.4.0", "pytest-asyncio>=0.21.0", "pytest-cov>=4.1.0"]
dev = ["ruff>=0.1.0", "invoke>=2.2.0", "pre-commit>=3.5.0"]
Spin up: uv sync --extra app. Run tests: uv run pytest. Add a dependency: uv add httpx. No pip install -e ., no activation ceremony, no requirements.txt drift. The lockfile (uv.lock) pins everything deterministically. uv sync --frozen in CI installs from the lockfile without re-resolving.
A cold sync of the full dependency tree — 40-odd packages including Playwright — takes under 10 seconds. Transporter beam, not turbolift.
Python 3.15: The Refit Enterprise
Python 3.15 is the most substantive release since 3.10 added structural pattern matching. Here are the features that actually show up in EventLoop.
PEP 810: Explicit Lazy Imports
This one matters most for AI tooling. Python 3.15 adds lazy as a soft keyword — deferred module loading until the name is first used:
# Before 3.15: every import evaluated at module load time
from app.chat.agent import agent # loads PydanticAI, Anthropic SDK, ...
from app.chat.intent_classifier import IntentClassifier # loads more
from shared.database import AsyncSessionLocal # triggers SQLAlchemy
# With lazy imports in 3.15 — the `lazy` soft keyword:
lazy from app.chat.agent import agent
lazy from app.chat.intent_classifier import IntentClassifier
lazy from shared.database import AsyncSessionLocal
# Each module loads only when its name is first referenced
When your FastAPI lifespan hook is the only place the agent runs, you don't need its dependencies loaded during test collection or scraper subprocess startup. Both lazy import foo and lazy from foo import bar work. It's a soft keyword, so existing code named lazy doesn't break.
One thing the PEP doesn't warn you about loudly enough: lazy changes module initialization order, and that will find your latent circular imports. EventLoop had one — web_search.py imports SearchPolicy from chatbot.py, and chatbot.py re-exports EventResult back from web_search.py. It never fired, because an eager from app.chat.chatbot import ... in executor.py always warmed sys.modules first. The moment that line became lazy from, the warm-up vanished and test collection died on an ImportError from a partially initialized module.
The fix was to move SearchPolicy into a third module, policy.py, that both files import from — so neither needs the other. The cycle was always there. Eager imports were hiding it. That's the honest version of "free wins, no code changes" — the wins are free, but lazy from is a load-bearing change to when your modules run, and the bug it surfaces was yours all along.
PEP 814: frozendict Built-in
EventLoop has a neighborhood alias mapping — computed once at startup, never mutated. Pre-3.15, you'd use types.MappingProxyType and fight the type checker. Now:
NEIGHBORHOOD_ALIASES: frozendict[str, str] = frozendict({
"wicker park": "Wicker Park",
"logan sq": "Logan Square",
"the loop": "Loop",
})
frozendict is hashable, genuinely immutable at runtime, and visible to your editor and type checker. No more silent mutations corrupting global state between requests.
PEP 661: sentinel Built-in
The sentinel pattern is everywhere in Python — distinguishing "not provided" from None, default markers, missing values. 3.15 formalizes it:
# sentinel is a built-in in 3.15 — no import needed, just like frozendict
MISSING = sentinel("MISSING")
def get_event_cost(event: EventResult, default=MISSING) -> str | None:
if event.cost is None:
if default is MISSING:
raise ValueError("cost not set and no default provided")
return default
return event.cost
Pre-3.15, _MISSING = object() would print as <object object at 0x10b3f2d30> in every traceback. sentinel("MISSING") has a name and a repr. Like frozendict, it's a built-in — no import statement required.
PEP 686: UTF-8 as Default Encoding
Finally. Python now defaults to UTF-8 everywhere without PYTHONUTF8=1 or encoding="utf-8" on every open(). For a scraper reading HTML from Chicago venue sites — which contain em-dashes, curly quotes, and the occasional stray \u2019 in event names — this eliminates a whole class of bugs that only appeared on Windows CI runners.
PEP 799: Tachyon — Profiling Package
Named after the faster-than-light particles from The Next Generation that appear whenever the writers needed to violate causality. The profiling package in Python 3.15's standard library reorganizes all built-in profilers:
profiling.tracing— deterministic tracing (replacescProfile, which still works as an alias)profiling.sampling— Tachyon, a statistical sampling profiler with zero instrumentation overhead
# Tracing profiler (high overhead, exact counts):
python -m profiling.tracing my_script.py
# Tachyon sampling (zero overhead, can attach to production processes):
python -m profiling.sampling my_script.py
For an async chatbot where I was puzzled about latency between the intent classifier, local DB search, and the PydanticAI agent loop, profiling.sampling is the right tool: no cProfile distortion of the async event loop, works on multiple threads, and can attach to a running process. The old profile module is deprecated in 3.15 (removal in 3.17).
JIT: 7–8% on x86-64, 11–12% on AArch64
Per the release announcement: the experimental JIT compiler has been significantly upgraded with 7–8% geometric mean performance improvement on x86-64 Linux over the standard interpreter, and 11–12% speedup on AArch64 macOS over the tail-calling interpreter. The Railway API service runs on Linux x86-64. The wins in EventLoop live in synchronous CPU-bound work: regex matching in the intent classifier, semantic embedding lookups, neighborhood polygon intersection. Free perf, no code changes.
3.15 vs 3.14: The Honest Comparison
| Feature | Python 3.14 | Python 3.15 |
|---|---|---|
| Immutable dicts | MappingProxyType workaround |
frozendict built-in, hashable, typed |
| Sentinel values | object() with bad reprs |
sentinel() built-in, named, inspectable |
| Default encoding | Platform-dependent, CI surprises | UTF-8 everywhere |
| Startup time | All imports eager | lazy import / lazy from soft keyword |
| Profiling | cProfile or third-party |
profiling.sampling (Tachyon) in stdlib |
| JIT performance | Marginal | 7-12% geometric mean |
3.14 → 3.15 is less dramatic than 3.9 → 3.10 (pattern matching). But it's a quality release. The things it adds are things you've been reaching for and finding absent: immutable dicts, named sentinels, UTF-8 sanity, lazy loading. Not flashy. Correct.
Why PydanticAI — And Why 3.15 Makes It Better
I get asked about agent frameworks constantly. My answer is PydanticAI, for reasons that became obvious building EventLoop: typed output, cloud-agnostic model selection, tests that run without a paid API call, and — critically for a streaming SSE API — it gets out of the way. The agent runs, the tools run, the executor yields events. No framework magic in the stream path.
EventLoop's agent is typed end-to-end:
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.anthropic import AnthropicModel
from app.chat.chatbot import SearchPolicy, search_local_db
from app.chat.web_search import search_google_events
agent = Agent(
model=AnthropicModel("claude-sonnet-5-5"),
system_prompt=SYSTEM_PROMPT,
deps_type=SearchPolicy,
)
@agent.tool
async def search_db(ctx: RunContext[SearchPolicy], query: str) -> list[EventResult]:
return await search_local_db(query, policy=ctx.deps)
@agent.tool
async def web_fallback(ctx: RunContext[SearchPolicy], query: str) -> list[EventResult]:
return await search_google_events(query)
SearchPolicy is a Pydantic model. Tool return types are Pydantic models. The agent validates tool arguments before they run. My editor knows the shape of every input and output. I can test without calling Anthropic (pydantic_ai.models.test.TestModel runs the full loop against a fake model). The model is a string I pass in, so switching providers doesn't touch tool definitions.
Add Python 3.15 and the next round of cleanups writes itself: frozendict for the neighborhood alias lookup in neighborhoods.py, so it's immutable at runtime and not just by convention; lazy from on the agent and intent classifier imports in executor.py; sentinel for the "field not returned vs. returned as null" distinction in EventResult. None of these are load-bearing on their own. They're the kind of small wins that compound.
The Three-Tier Architecture: Agentic Thinking Made Concrete
The part I'm most proud of isn't the agent — it's what gates it. Running Claude Sonnet on every message is expensive. The solution is a three-tier routing pipeline:
User message
↓
[1] Regex fast-path → CHICAGO_EVENTS (zero LLM cost)
↓ (if ambiguous)
[2] Claude Haiku intent classifier (~30× cheaper per token than Sonnet)
→ CHICAGO_EVENTS | CHICAGO_INFO | EVENTS_GENERAL | OUT_OF_SCOPE | FAREWELL
↓ (if event-related)
[3] Claude Sonnet via PydanticAI agent
→ search_local_db → search_google_events (fallback when local is thin)
The Haiku classifier returns a structured IntentClassification model — confidence score included. High confidence routes through. Low confidence gets a clarification prompt.
Building this pipeline is where agentic coding became concrete for me. I directed an AI pair programmer (Cline, running Claude) to build the initial classifier. I reviewed the output. I caught the subtle bugs — the farewell classifier kept marking "anything cheaper?" as a farewell because "appreciate it" scored positive sentiment. Sentiment and intent are not the same thing.
The docstring in intent_classifier.py now says it plainly:
"👍" positive -> farewell
"no worries, I'll figure it out" neutral -> farewell
"cool cool cool" positive -> farewell
"appreciate it - anything cheaper?" positive -> NOT farewell
"ugh, nothing good on then?" negative -> NOT farewell
Two positives on opposite sides and a negative that's still a request. The model wrote the code; I did the thinking.
That's the skill shift from my first post: less syntax, more strategy.
One Python Version. For Everything.
EventLoop runs Python 3.15 for both the API and the scraper. This wasn't always the plan — when I started, the scraper was pinned to 3.14 because greenlet (a SQLAlchemy dependency) didn't have cp315 wheels yet, and the Microsoft playwright/python Docker image hard-codes its own Python version.
Both blockers are gone. greenlet 3.5.6 (released September 14, 2026) ships full cp315-manylinux wheels. playwright 1.63.0 is py3-none — pure Python, any version. And we dropped the Microsoft base image entirely: the scraper now uses python:3.15.0rc3-slim and installs Playwright's Chromium browser binary itself:
FROM python:3.15.0rc3-slim
# (python:3.15-slim once Docker Hub publishes the final tag)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libc6-dev \
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
libpango-1.0-0 libcairo2 libasound2t64 libatspi2.0-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir uv==0.12.24
COPY pyproject.toml uv.lock ./
# The scraper only needs the `parser` extra — `app` pulls in uvicorn[standard]
# which requires gcc to build httptools from source (no cp315 wheel yet).
RUN uv sync --frozen --extra parser --no-dev --no-install-project
RUN uv run playwright install chromium
One Python version. One lockfile. No subprocess boundary. No "which environment is this running in?" when a bug appears at 2am.
The extras story is sharpest here: --extra parser excludes the entire FastAPI and PydanticAI stacks from the scraper image. They're not installed at all — so they cost zero import time, not just lazy-deferred time. uv's dependency groups do the work before the process even starts.
Zero Ruff Errors, Every Suppression Documented
The project has zero ruff errors across src/, tests/, scripts/, and eval/. Every suppression is explained:
# Scrapers construct naive datetimes intentionally: event dates are stored as
# local-midnight naive values. Timezone-aware datetimes would require migrating
# the entire events table and all downstream comparisons.
"src/scrapers/**" = ["DTZ", ...]
A noqa comment nobody can explain is a landmine for future-you. Document the why.
Actually Shipping It: Railway + PostgreSQL
Three services, one Railway project, one PostgreSQL database — 90 neighborhoods, 68 venues, events growing every six hours:
api— Python 3.15, FastAPI, always-on,rootDirectory=backend, picks upDockerfileandrailway.tomlautomaticallyfrontend— nginx,rootDirectory=frontend. Proxies/api/*to the backend viaenvsubstat container start —${API_URL}gets substituted into the nginx config at runtime, so no rebuild needed when the backend URL changesscraper— Python 3.15 + Playwright, cron0 */6 * * *, separateDockerfile.scraperpointed atrailway.scraper.toml
The whole thing is live at eventloop.city — custom domain on Porkbun, ALIAS @ → Railway, SSL auto-provisioned by Let's Encrypt through Railway. Go find something to do in Chicago.
All three connect to the same Railway PostgreSQL service via ${{Postgres.DATABASE_URL}}. One thing I learned: Railway can inject that URL as postgresql+psycopg:// (the sync psycopg3 dialect), not just plain postgresql://. The fix is to normalise before SQLAlchemy sees it:
def _normalise_db_url(url: str) -> str:
for prefix, replacement in [
("postgresql+psycopg2://", "postgresql+asyncpg://"),
("postgresql+psycopg://", "postgresql+asyncpg://"),
("postgres://", "postgresql+asyncpg://"),
("postgresql://", "postgresql+asyncpg://"),
]:
if url.startswith(prefix):
return replacement + url[len(prefix):]
return url
And after migrating from local SQLite to Railway Postgres: Postgres sequences. A bulk migration that inserts rows with explicit IDs doesn't advance the SERIAL sequence — every new INSERT hits a unique-key collision. Fixed with setval(pg_get_serial_sequence(...), MAX(id)) on each table. Ship an admin endpoint for this, not a one-liner you have to remember.
Everything else (build config, healthcheck path, cron schedule, restart policy) lives in committed railway.toml files alongside the Dockerfiles. Total cost: ~$10–12/month on the Hobby plan. The Anthropic API bill is separate and scales with actual usage.
Stardate 2026.282: Ship It
Python 3.15.0 final dropped today — October 9th, 2026 — from release manager Hugo van Kemenade, with Ned Deily and Steve Dower on the release team. 5,643 commits from 1,012 contributors. As a former PSF Chairperson, I never get tired of reading that number. Open source at scale.
To celebrate, Barry Warsaw shipped whatsnewt — a TUI text adventure through What's New in Python 3.15. Eighteen puzzles, each one a real 3.15 feature you have to actually write and run code to solve. Type checking verified by pyrefly. Easter eggs. Run it:
uvx --python 3.15 whatsnewt
You wake up in the Startup Foyer, inside the interpreter, and work your way to the Release Gate. It's exactly the kind of thing that makes me proud to be part of this community.
uv 0.12.24 is ready. Here's what I'd tell you starting a Python project today:
- Use uv. The toolchain friction it removes is real and immediate.
uv sync --frozenin CI,uv runfor everything else. - Pin Python 3.15.
frozendict,sentinel, UTF-8 everywhere,lazyimports — right answers the language needed for a decade. - Use PydanticAI for agent work. Typed output is not optional when calling LLMs in production and you want tests that don't burn API credits.
- Build the cheapest gate first. A Haiku intent classifier at ~$0.0001/call vs $0.003 for Sonnet is a 30× win you can see in your billing dashboard.
- Let the AI pair programmer scaffold. Review it like a junior engineer's PR. The skill is in the review, not the typing.
- Postgres in production, not SQLite. You will hit sequence desync and you will hit it at the worst time.
The Enterprise got refitted between films. Upgraded nacelles, better computers, same crew with the same values and better tools. Python 3.15 + uv is that: the language and toolchain we already know, refit for the work we're actually doing now. The mission hasn't changed. The ship is just better.
Live long, Python & prosper. 🖖
EventLoop is live at eventloop.city — source at github.com/lorenanicole/event-loop. Python 3.15.0 final released 2026-10-09 — announcement, download. All PEP numbers and feature descriptions reference the official Python 3.15 What's New. uv 0.12.24 is the current stable release as of this writing.