Chapter 36: Upgrading from v2 to v3#
1. Overview#
Tina4 v3 is a ground-up rewrite. Zero external dependencies. Pure Python stdlib. The framework does more with less.
The concepts are the same -- routing, ORM, templates, migrations, authentication. The APIs are cleaner. The internals are simpler. If you built something with v2, you will recognise everything in v3. But the import paths, decorators, and project layout have all changed.
This chapter covers every breaking change and the migration path for each one. Read it top to bottom before you start, then use the checklist at the end.
Step 0: Run the Automated Upgrade Command#
Before doing anything manually, run the automated upgrade tool:
cd your-v2-projecttina4 i-want-to-stop-using-v2-and-switch-to-v3This command automatically:
- Moves
routes/,orm/,templates/,scss/,public/,app/,locales/,seeds/intosrc/ - Updates your
pyproject.tomldependency from v2 to v3
After running the command, continue with the steps below for the changes that require manual attention.
2. Package and Installation#
v2 installed with pip and pulled in external dependencies:
# v2pip install tina4_pythonv3 uses uv (recommended) or pip. Zero external dependencies:
# v3uv add tina4-pythonโ# or with pippip install tina4-pythonv3 requires Python 3.12 or later. Check your version:
python --versionIf you are on 3.11 or earlier, upgrade Python first. v3 uses features from 3.12 that cannot be backported.
3. Project Structure Changes#
v2 used a flat structure. Routes, models, and templates could live anywhere. v3 expects a standard layout:
project/ .env app.py src/ routes/ products.py users.py orm/ product.py user.py templates/ index.twig layout.twig app/ helpers.py services.pyv3 auto-discovers every .py file in src/ and its subdirectories. No manual imports. No registration. Drop a file in src/routes/ and it loads at startup.
The framework adds your project root (CWD) to sys.path, so imports like this work everywhere:
from src.app.helpers import format_currencyfrom src.orm.product import ProductIf you have v2 code scattered across the project root, move it into the appropriate src/ subdirectory. The tina4 init python . command creates this structure for you.
4. Routing Changes#
Decorators#
v2 used @route.get(). v3 uses standalone decorators:
# v2from tina4_python import routeโ@route.get("/products")def list_products(request, response): return response.json({"products": []})โ# v3from tina4_python.core.router import getโ@get("/products")async def list_products(request, response): return response.json({"products": []})Import all HTTP methods from one place:
from tina4_python.core.router import get, post, put, patch, deleteAuth Defaults#
v3 changes the default auth behaviour. GET routes are public. POST, PUT, PATCH, and DELETE routes require authentication.
To make a write route public, use @noauth():
from tina4_python.core.router import post, noauthโ@noauth()@post("/api/feedback")async def submit_feedback(request, response): return response.json({"status": "received"}, 201)To protect a GET route, use @secured():
from tina4_python.core.router import get, securedโ@secured()@get("/admin/dashboard")async def admin_dashboard(request, response): return response.render("admin/dashboard.twig")Decorator Order#
Order matters. Stack them outermost to innermost:
@noauth()or@secured()(auth control)@description("...")(Swagger docs)@get("/path")or@post("/path")(route binding)
@noauth()@description("Submit anonymous feedback")@post("/api/feedback")async def submit_feedback(request, response): return response.json({"status": "received"}, 201)Middleware#
Middleware uses a decorator. It works in any order relative to the route decorator:
from tina4_python.core.router import get, middlewarefrom src.app.rate_limiter import RateLimiterโ@middleware(RateLimiter)@get("/api/data")async def get_data(request, response): return response.json({"data": []})Wildcard Routes#
Wildcard routes now work correctly in v3:
@get("/api/*")async def catch_all(request, response): return response.json({"path": request.path})5. Database Changes#
Connection URL#
v2 imported Database from the top-level package. v3 imports from tina4_python.database:
# v2from tina4_python import Databasedb = Database("sqlite3", "data/app.db")โ# v3from tina4_python.database import Databasedb = Database("sqlite:///data/app.db")URL format examples:
# SQLitedb = Database("sqlite:///data/app.db")โ# PostgreSQLdb = Database("postgresql://localhost:5432/myapp", "user", "password")โ# Firebirddb = Database("firebird://localhost:3050//var/data/app.fdb", "SYSDBA", "masterkey")Keyword Arguments#
v3 passes **kwargs through to the underlying driver. Useful for Firebird charset, connection timeouts, and other driver-specific options:
db = Database("firebird://localhost:3050//var/data/app.fdb", "SYSDBA", "masterkey", charset="ISO8859_1")Firebird Column Names#
v3 lowercases all Firebird column names. This makes Firebird consistent with every other adapter. Update your code:
# v2email = row["EMAIL"]first_name = row["FIRST_NAME"]โ# v3email = row["email"]first_name = row["first_name"]Search your codebase for uppercase column access. Every instance needs updating.
Firebird Drivers#
v3 supports both firebird-driver (modern, recommended) and fdb (legacy). It tries firebird-driver first, falls back to fdb. Install the one you prefer:
uv add firebird-driver # recommended# oruv add fdb # legacy fallbackTransactions#
Use the database object's transaction methods. Never use raw SQL for transaction control:
# Correctdb.start_transaction()db.execute("INSERT INTO products (name) VALUES (?)", ["Widget"])db.commit()โ# Wrong -- do not do thisdb.execute("BEGIN")db.execute("INSERT INTO products (name) VALUES (?)", ["Widget"])db.execute("COMMIT")Connection Pooling#
v3 adds connection pooling. Pass the pool parameter:
db = Database("postgresql://localhost:5432/myapp", "user", "password", pool=4)This creates a pool of 4 connections. The framework manages checkout and return.
6. ORM Changes#
Field Definitions#
v2 field definitions varied. v3 uses typed field classes:
# v2class Product: table_name = "products" id = None name = None price = Noneโ# v3from tina4_python.orm import ORM, bind_database, IntegerField, StringField, FloatFieldโclass Product(ORM): table_name = "products" id = IntegerField(primary_key=True) name = StringField() price = FloatField()Binding the Database#
Call bind_database(db) before using any ORM class. Do this once in app.py:
from tina4_python.database import Databasefrom tina4_python.orm import bind_databaseโdb = Database("sqlite:///data/app.db")bind_database(db)Auto Mapping#
Set auto_map = True on your ORM class for automatic snake_case to camelCase field mapping. This exists for cross-language parity (Python, PHP, Ruby, Node.js all share the same ORM concepts):
class UserProfile(ORM): table_name = "user_profiles" auto_map = True id = IntegerField(primary_key=True) first_name = StringField() last_name = StringField()Field Mapping#
For columns that do not follow conventions, use field_mapping:
class LegacyUser(ORM): table_name = "tbl_users" field_mapping = { "user_id": "usr_id", "email": "usr_email", "name": "usr_full_name" } user_id = IntegerField(primary_key=True) email = StringField() name = StringField()Relationships#
v3 adds has_many, has_one, and belongs_to with eager loading:
from tina4_python.orm import ORM, IntegerField, StringField, has_many, belongs_toโclass Customer(ORM): table_name = "customers" id = IntegerField(primary_key=True) name = StringField() orders = has_many("Order", "customer_id")โclass Order(ORM): table_name = "orders" id = IntegerField(primary_key=True) customer_id = IntegerField() total = FloatField() customer = belongs_to("Customer", "customer_id")7. Template Engine Changes#
Frond Replaces Template#
v2 used a Template class. v3 uses the Frond engine, which is Jinja2/Twig-compatible:
# v2return response.template("page.html", {"title": "Home"})โ# v3return response.render("page.twig", {"title": "Home"})Frond is a singleton. It is created once at startup and reused for every request.
Custom Filters#
Register filters in app.py before calling run():
from tina4_python.frond import Frondโdef money(value): return f"${value:,.2f}"โFrond.add_filter("money", money)Use in templates:
{{ product.price | money }}Custom Globals#
Add global variables available in every template:
Frond.add_global("APP_NAME", "My Store")Frond.add_global("YEAR", 2026)<footer>© {{ YEAR }} {{ APP_NAME }}</footer>New Template Features#
v3 Frond supports method calls on dict values:
{{ user.t("greeting") }}Python slice syntax works:
{{ text[:10] }}{{ items[1:3] }}8. Migration Tracking Table#
v2 used a tina4_migration table with a description column.
v3 uses an expanded schema:
| Column | Type | Description |
|---|---|---|
id | integer | Auto-increment primary key |
migration_name | VARCHAR(500) | The migration filename (unique) |
description | VARCHAR(500) | Migration description |
batch | integer | Batch number |
executed_at | VARCHAR(50) | ISO-8601 timestamp string of when it ran |
passed | integer | 1 marks the migration as applied |
You do not need to alter the table yourself. Run tina4 migrate and v3 auto-detects the v2 schema. It adds the missing columns and backfills migration_name by matching each v2 description to the migration file on disk. Your existing migration history is preserved. If the v2 table holds passed = 0 rows (migrations that failed under v2), the upgrade logs a warning naming them. Those migrations re-apply on the next tina4 migrate: the runner deletes the stale passed = 0 row and writes a fresh passed = 1 row, so a previously-failed migration runs again cleanly instead of colliding on the unique migration_name.
tina4 migrateThat is it. No manual SQL. No data loss.
9. Authentication Changes#
v2 had various auth approaches. v3 consolidates into an Auth class:
from tina4_python.auth import Authโ# Generate a tokentoken = Auth.get_token({"user_id": 42, "role": "admin"})โ# Validate a tokenis_valid = Auth.valid_token(token)โ# Extract the payloadpayload = Auth.get_payload(token)Password hashing:
hashed = Auth.hash_password("my-secret-password")matches = Auth.check_password("my-secret-password", hashed) # True# Signature: check_password(password, hashed) -- plain text first, hash secondJWT uses HMAC-SHA256. Set the signing key in .env:
TINA4_SECRET=your-long-random-secret-keyToken lifetime defaults to 60 minutes. Override with:
TINA4_TOKEN_LIMIT=12010. Session Changes#
v2 had basic file-based sessions. v3 supports pluggable backends.
Set the backend in .env:
TINA4_SESSION_BACKEND=fileAvailable backends:
| Value | Backend | Package Required |
|---|---|---|
file | Local filesystem (default) | None |
redis | Redis | redis |
valkey | Valkey | valkey |
mongodb | MongoDB | pymongo |
database | Database table | None |
Session cookies default to SameSite=Lax. Override with:
TINA4_SESSION_SAMESITE=Strict11. New Features in v3#
v3 adds capabilities that did not exist in v2. Each has its own chapter:
- Events system -- publish and subscribe to application events (Chapter 13)
- GraphQL engine -- schema-first GraphQL with resolvers (Chapter 22)
- WSDL/SOAP services -- consume and expose SOAP endpoints
- WebSocket with Redis backplane -- real-time with horizontal scaling (Chapter 23)
- Response caching middleware -- cache responses with TTL and invalidation (Chapter 11)
- DI container -- dependency injection for services and repositories
- Queue system -- RabbitMQ, Kafka, and MongoDB backends (Chapter 12)
- Swagger/OpenAPI auto-generation -- live API docs from route decorators (Chapter 20)
- Auto-CRUD endpoint generator -- CRUD routes from ORM models with one line
- Seeder/fake data -- populate databases with realistic test data
- i18n translations -- multi-language support with translation files
- AI coding assistant context --
tina4 aigenerates context for LLM coding tools - Error overlay in dev mode -- stack traces rendered in the browser
- SCSS auto-compilation --
.scssfiles compiled to CSS on change
You do not need to adopt all of these at once. They are opt-in. Migrate your existing code first, then add new features as you need them.
Common Pitfalls#
1. POST/PUT/DELETE routes now require authentication#
This is the most common upgrade issue. In v2, all routes were public by default. In v3, only GET routes are public -- POST, PUT, PATCH, and DELETE require a Bearer JWT token.
Symptom: Working v2 endpoints return 401 Unauthorized after upgrading.
Fix: Add @noauth to any write route that should remain public.
Find affected routes:
grep -rn "@post\|@put\|@patch\|@delete" src/routes/Review each match -- if the endpoint should be public (webhooks, public forms, etc.), add the @noauth() decorator.
2. Database connection strings changed#
v2 used driver-specific classes. v3 uses URL format:
# v2db = DatabaseSqlite3("data/app.db")# v3db = Database("sqlite:///data/app.db")3. Template engine renamed#
v2: Template.render() -- v3: Frond.render() (or response.render())
The Twig syntax is the same -- your .twig files work unchanged. Only the Python API call changes.
12. Step-by-Step Migration Checklist#
Follow this order. Each step builds on the previous one.
- Install v3
uv add tina4-python- Create the v3 project structure
tina4 init python .This creates src/routes/, src/orm/, src/templates/, and src/app/ if they do not exist. It will not overwrite existing files.
- Move route files to
src/routes/
Update imports from from tina4_python import route to from tina4_python.core.router import get, post, put, patch, delete. Replace @route.get() with @get(). Make handler functions async.
- Move ORM models to
src/orm/
Replace raw field definitions with typed fields: IntegerField, StringField, FloatField, TextField. Add bind_database(db) in app.py.
- Move templates to
src/templates/
Replace response.template() calls with response.render(). Templates are Twig-compatible -- most existing templates work without changes.
- Update
app.py
from tina4_python.core import run run()- Update database connections
Switch to URL format in .env:
TINA4_DATABASE_URL=sqlite:///data/app.db- Run migrations
tina4 migrateThe tracking table upgrades automatically.
- Fix Firebird column names
Search for uppercase column access (row["UPPER"]) and change to lowercase (row["upper"]). Only applies if you use Firebird.
- Start the server and test
tina4 serveHit every route. Check the logs for errors.
- Run the doctor
tina4 doctorThis verifies your project structure, database connection, and configuration.
That covers the migration. Most projects take under an hour. The biggest time sink is updating import paths and decorators -- a find-and-replace handles the bulk of it. Once you are running on v3, you get zero dependencies, faster startup, and access to every new feature listed in section 11.