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-project
tina4 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:
# v2
pip install tina4_pythonv3 uses uv (recommended) or pip. Zero external dependencies:
# v3
uv add tina4-python
# or with pip
pip 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_currency
from 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:
# v2
from tina4_python import route
@route.get("/products")
def list_products(request, response):
return response.json({"products": []})
# v3
from 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, middleware
from 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:
# v2
from tina4_python import Database
db = Database("sqlite3", "data/app.db")
# v3
from tina4_python.database import Database
db = Database("sqlite:///data/app.db")URL format examples:
# SQLite
db = Database("sqlite:///data/app.db")
# PostgreSQL
db = Database("postgresql://localhost:5432/myapp", "user", "password")
# Firebird
db = 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:
# v2
email = row["EMAIL"]
first_name = row["FIRST_NAME"]
# v3
email = 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
# or
uv add fdb # legacy fallbackTransactions
Use the database object's transaction methods. Never use raw SQL for transaction control:
# Correct
db.start_transaction()
db.execute("INSERT INTO products (name) VALUES (?)", ["Widget"])
db.commit()
# Wrong -- do not do this
db.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:
# v2
class Product:
table_name = "products"
id = None
name = None
price = None
# v3
from tina4_python.orm import ORM, orm_bind, IntegerField, StringField, FloatField
class Product(ORM):
table_name = "products"
id = IntegerField(primary_key=True)
name = StringField()
price = FloatField()Binding the Database
Call orm_bind(db) before using any ORM class. Do this once in app.py:
from tina4_python.database import Database
from tina4_python.orm import orm_bind
db = Database("sqlite:///data/app.db")
orm_bind(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:
# v2
return response.template("page.html", {"title": "Home"})
# v3
return 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.template 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 |
|---|---|---|
migration_id | text | Unique identifier |
description | text | Migration description |
batch | integer | Batch number |
executed_at | timestamp | When it ran |
passed | boolean | Whether it succeeded |
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_id from description. Your existing migration history is preserved.
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 token
token = Auth.get_token({"user_id": 42, "role": "admin"})
# Validate a token
is_valid = Auth.valid_token(token)
# Extract the payload
payload = Auth.get_payload(token)Password hashing:
hashed = Auth.hash_password("my-secret-password")
matches = Auth.check_password(hashed, "my-secret-password") # TrueJWT uses HMAC-SHA256. Set the signing key in .env:
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:
# v2
db = DatabaseSqlite3("data/app.db")
# v3
db = 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
bashuv add tina4-pythonCreate the v3 project structure
bashtina4 init python .This creates
src/routes/,src/orm/,src/templates/, andsrc/app/if they do not exist. It will not overwrite existing files.Move route files to
src/routes/Update imports fromfrom tina4_python import routetofrom tina4_python.core.router import get, post, put, patch, delete. Replace@route.get()with@get(). Make handler functionsasync.Move ORM models to
src/orm/Replace raw field definitions with typed fields:IntegerField,StringField,FloatField,TextField. Addorm_bind(db)inapp.py.Move templates to
src/templates/Replaceresponse.template()calls withresponse.render(). Templates are Twig-compatible -- most existing templates work without changes.Update
app.pypythonfrom tina4_python.core import run run()Update database connections Switch to URL format in
.env:bashDATABASE_URL=sqlite:///data/app.dbRun migrations
bashtina4 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
bashtina4 serveHit every route. Check the logs for errors.
Run the doctor
bashtina4 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.