Jongo
One language for the whole web app. Jongo is a full-stack Python web framework in the spirit of Django: ORM, migrations, auth, sessions, CSRF protection and an admin site. The difference is that your frontend is Python too. Components render on the server for a fast first paint, then compile to JavaScript and come alive in the browser. They call your server code with a plain await.
There's no template language, no separate JS project and no build step. It has zero dependencies.
from jongo import Jongo, component, db, server, state
from jongo.html import *
app = Jongo(__name__, database="db.sqlite3")
class Todo(db.Model): # a database table
title = db.Text(max_length=200)
done = db.Bool(default=False)
@server # a server function, callable from the browser
def add_todo(title: str) -> dict:
return Todo.create(title=title).to_dict()
@component # UI: Python here, JavaScript in the browser
def TodoList(todos):
items = state(todos)
draft = state("")
async def add(event):
todo = await add_todo(draft.value) # runs on the server
items.value = items.value + [todo]
draft.value = ""
return div(
form(
input_(value=draft.value, on_input=lambda e: draft.set(e.target.value)),
button("Add"),
on_submit=add,
),
ul([li(t["title"], key=t["id"]) for t in items.value]),
)
@app.page("/") # route + view + UI, together
def home():
return TodoList(todos=Todo.all())
jongo dev # → http://localhost:8000, reloads when you save
Documentation
- The guide — the long version: the request model, routing, components, every hook, server functions, the ORM, migrations, PostgreSQL, channels, auth, security, testing, deployment, exactly what compiles to the browser, and a troubleshooting table.
- Patterns — thirteen complete, runnable apps: CRUD, auth, search as you type, pagination, optimistic UI, live chat, a live dashboard, modals with server validation, master-detail, uploads, background work, testing, deployment.
- Benchmarks — against Django and FastAPI, with the harness and the caveats.
- The docs site is itself a Jongo app:
jongo dev docs/docs_site.py --port 8790.
Why
In Django, one feature is spread across models.py, urls.py, views.py, a template, a form class and usually some JavaScript. In Jongo it's one idea in one place:
| Concern | Django | Jongo |
|---|---|---|
| URL | urls.py |
@app.page("/todos/<id>") |
| View | views.py |
the decorated function |
| Template | .html + template language |
Python functions: div(h1(title)) |
| Interactivity | separate JavaScript | the same component, compiled |
| AJAX endpoint | view + URL + fetch + JSON |
@server function, called with await |
| Input validation | form classes | type hints (title: str, todo: Todo) |
| Migrations | makemigrations + files |
jongo migrate diffs models against the DB |
Quick start
pip install jongo # Python 3.10+
jongo new mysite
cd mysite
jongo dev
The example app lives in examples/todo/app.py. It's a polished todo list with a detail page and a focus timer, all in one file.
Pages and routing
@app.page("/posts/<id>", title="Post")
def post(request, id: int, preview: bool = False): # `id: int` makes the route only match numbers
...
- Path parameters use
<name>or<converter:name>, with the convertersstr int float slug path uuid. An annotation likeid: intpicks the converter for you. - Arguments are filled by name.
requestis the current request, path parameters come from the URL, and anything else is read from the query string and type-checked. - A page returns UI,
Page(ui, title=..., status=..., head=[...]), or aResponse, such asredirect("/login"). - Plain handlers:
@app.get,@app.postand@app.route(path, methods=[...])return aResponse, HTMLstr, JSON-abledict/list, or UI. - Layouts:
@app.layoutwraps every page in a component. When a link switches pages, the layout's state survives. - Errors: raise
NotFound("..."),Forbidden()orHTTPError(status, message), and customise the page with@app.errorhandler(404). - Access control:
login_required=Trueandadmin_required=Truework on pages and routes.
Same-origin links are handled by the client-side router. It fetches the next page as JSON and patches the DOM, with no full reload. Opt out with a(..., data_reload=True).
Components
@component
def Card(title, children, tone="plain"):
open_ = state(True)
return section(
h2(title, on_click=lambda e: open_.set(not open_.value)),
open_.value and div(children),
class_=["card", {"card-warning": tone == "warning"}],
style={"padding": 16, "border_radius": 12},
)
Elements come from from jongo.html import *.
- Children: positional arguments are children, which can be strings, elements, lists or
None. - Attributes: keyword arguments become attributes.
class_accepts a string, list or{name: condition}dict.styletakes a dict. Snake_case becomes kebab-case, and numbers getpx.on_click,on_input,on_submit… attach event handlers. Submit handlers callpreventDefault()for you.aria_label,data_id→aria-label,data-id.
- Name clashes: Python builtins get a trailing underscore:
input_,del_,map_. - Other helpers:
key=for list items,raw(html)for trusted markup,h("my-element")for custom tags.
Hooks and browser helpers:
state(initial) |
reactive value. Set .value, or call .set(v) / .update(fn), to re-render |
effect(fn, deps=None) |
runs in the browser after render; deps=[] runs it once; return a cleanup function |
ref() |
pass as ref= to get the DOM node in .current |
navigate(url) / refresh() |
client-side navigation / re-run the current page |
form_values(event) |
dict of a form's fields |
js.window, js.localStorage, js.fetch… |
browser globals; e.prevent_default() maps to preventDefault() |
Python that runs in the browser
The compiler supports most everyday Python:
- functions (default,
*args, keyword-only and**kwargsparameters),lambda, closures andnonlocal if/elif/else,for/whileloops withelse,try/except/finally,raise- list/dict/set comprehensions, generator expressions, the walrus operator, unpacking
- f-strings with format specs,
%formatting andstr.format async/await
It keeps Python semantics:
- Empty lists are falsy.
[1] + [2]concatenates.xs[-1]indexes from the end.-7 // 2 == -4.==compares structures.- A missing dict key raises
KeyError.
The common methods of str, list, dict and set work, as do the math, random, json and time modules. A test suite runs the same functions in Python and in Node and requires identical results.
Anything that can't run in a browser is a compile error with a hint, reported at startup:
- classes
with- imports inside functions
- server-only modules
- touching a database model directly
One deliberate improvement: closures created in a for loop capture each item, so button(on_click=lambda e: remove(todo)) in a loop does what you mean.
Server functions
@server
def rename(request, todo: Todo, title: str) -> dict: # `todo: Todo` loads the row, 404 if missing
if request.user is None:
raise HTTPError(401, "Log in first")
todo.title = title
todo.save()
return todo.to_dict()
- In a component, call it with
await rename(todo_id, "New title"). Keyword arguments work too. - Arguments are validated against the type hints before your code runs. Supported hints:
str,int,float,bool,list[...],dict[...],Optional,Literal, dataclasses and models. - Return values can be JSON-able data or UI elements (rendered in the browser), or a
redirect(url)that navigates. - Failures raise
ServerErrorin the browser, with.status,.typeand field.errors. - Options:
@server(login_required=True),@server(admin_required=True), and@server(refresh=True)to re-run the page loader after each call. - Security: every call is CSRF-protected. Only functions you decorate are exposed.
Real-time
Push from a server function straight into a mounted component. Declare the channel, and the declaring function authorises each subscription — an undeclared channel cannot be subscribed to at all, the same rule that keeps undecorated functions off the RPC boundary.
@app.channel("room:<int:id>")
def room(request, id):
return id in request.session.get("rooms", [])
@server
def post(request, room: int, text: str) -> dict:
message = Message.create(room_id=room, text=text).to_dict()
broadcast(f"room:{room}", message) # -> every subscribed browser
return message
@component
def Chat(room):
messages = state([])
live(f"room:{room}", lambda message: messages.set([*messages.value, message]))
return ul([li(m["text"], key=m["id"]) for m in messages.value])
live() is a hook like state() and effect(): the subscription opens when the component
mounts, moves when the channel changes, and closes when it unmounts. One SSE connection per
tab carries every channel the page asked for, and reconnects with backoff if it drops.
- It is a live feed, not a queue: a browser that reconnects sees what happens next, not what it missed, and a connection that falls more than 100 messages behind sheds its oldest.
- The hub is per process. With one worker and threads (the default
jongo run) a broadcast reaches every connection. Across multiple worker processes, each worker only reaches its own connections — put a shared bus in front if you need that. - Each streaming connection holds a worker thread, so raise
--threadsfor a chatty app.
Styles
from jongo import css, global_css
s = css(
card={"padding": 16, "border_radius": 12, ":hover": {"background": "#fafafa"},
"& h2": {"margin": 0}, "@media (max-width: 600px)": {"padding": 8}},
)
div(h2("Hi"), class_=s.card) # class="card-3f9a1c"
Class names are scoped. All stylesheets are served together from /_jongo/app.css.
Database
class Author(db.Model):
name = db.Text(max_length=100, unique=True)
class Book(db.Model):
title = db.Text(max_length=200)
author = db.ForeignKey(Author, related_name="books")
published = db.Date(null=True)
tags = db.JSON(default=list)
class Meta:
ordering = ["-published"]
Book.filter(author__name__icontains="le guin", published__gte=date(1970, 1, 1)).exclude(tags=[])[:10]
Book.filter(db.Q(title__startswith="The") | db.Q(tags__contains="classic")).count()
author.books.create(title="The Dispossessed")
with db.transaction():
...
- Fields:
Text Int Float Bool DateTime Date JSON ForeignKey. - Lookups:
exact iexact contains icontains startswith endswith gt gte lt lte in isnull ne, plus relation traversal with__. - Migrations have no files.
jongo migratecompares your models to the live schema.- It creates tables, adds columns and indexes, and changes a column when its type changes.
- It only drops columns when you pass
--allow-destructive. jongo migrate --planshows the SQL first.jongo devapplies the safe changes automatically.
SQLite or PostgreSQL
SQLite is the default and needs no setup. Point the same models at PostgreSQL with a URL:
app = Jongo(__name__, database="postgres://user:pw@localhost/app")
# or: JONGO_DATABASE=postgres://user:pw@localhost/app
pip install "jongo[postgres]" # adds the psycopg driver; the core stays dependency-free
Nothing else changes — the same models, queries, migrations and tests run on both, and the test suite is run against both. The backends differ only where the database does:
| SQLite | PostgreSQL | |
|---|---|---|
| Changing a column | rebuilds the table | ALTER TABLE … ALTER COLUMN |
| Dropping a column | rebuilds when the column is referenced | drops in place |
Case-sensitive contains |
GLOB |
LIKE |
Case-insensitive icontains |
LIKE |
ILIKE |
| New row ids | AUTOINCREMENT |
identity column, kept in step with explicit ids |
Both store the same representations — datetimes and dates as ISO-8601 text, booleans as
integers, JSON as text — so a model behaves identically on either one. That is a deliberate
trade for consistency: it means no value changes meaning when you move an app from SQLite to
PostgreSQL, at the cost of not using timestamptz/jsonb natively.
Auth and admin
from jongo.auth import User, authenticate, login, logout
app.admin() # generated admin at /admin
- Users:
User.create_user(...), thenauthenticate,login(request, user)andlogout(request).request.useris available in pages, routes and server functions. - Passwords use PBKDF2-SHA256. Changing a password signs out the user's other sessions.
- The admin site lists, searches, sorts, creates, edits and deletes rows for every model, with forms built from your field types. Create the first account with
jongo createadmin.
Sessions, CSRF, security
- Sessions are HMAC-signed cookies:
request.session["cart"] = [...]. SetJONGO_SECRET_KEYin production. In dev, a key is generated into.jongo/secret. - CSRF: unsafe requests need a token. Browser code sends it automatically. Classic HTML forms need
input_(type="hidden", name="csrf_token", value=request.csrf_token). Cross-originOriginheaders are rejected. - Escaping: text is always HTML-escaped.
raw()is the explicit escape hatch. - Data sent to the browser: props are converted to JSON.
User.to_dict()never includes password hashes.
Testing
def test_add(app):
client = app.test_client()
assert client.get("/").status == 200
todo = client.rpc(add_todo, "Write tests") # full HTTP round trip, CSRF included
assert client.navigate("/")["title"] == "Todos"
CLI
| Command | |
|---|---|
jongo new NAME |
create a project |
jongo dev [app.py] [--port] |
dev server: auto-reload, live browser reload, error overlay, debug pages |
jongo run [--host --port --migrate] |
production server (threaded) |
jongo migrate [--plan] [--allow-destructive] |
sync the schema |
jongo createadmin |
create an admin user |
jongo routes |
list routes |
jongo shell |
Python shell with your models |
jongo build |
compile components and report errors (good for CI) |
Deployment
app is a standard WSGI application:
JONGO_SECRET_KEY=... gunicorn app:app # or: jongo run --port 8000 --migrate
How it works
request ─▶ route ─▶ page function ─▶ UI tree ─┬─▶ rendered to HTML on the server ─▶ fast first paint
└─▶ serialised as JSON ──────────────▶ browser hydrates it
with components compiled
@component (Python source) ──ast──▶ JavaScript ──▶ /_jongo/app.js from the same Python
@server call in browser ──POST /_jongo/rpc/<id> (CSRF, JSON, type-checked)──▶ your function
jongo/compiler/turns Python ASTs into JavaScript. Free names resolve against the live Python objects, so the compiler knows whetheradd_todois a server function, a component, a helper or a constant.jongo/compiler/pyrt.jsprovides Python semantics in the browser.jongo/compiler/dom.jsis the virtual DOM, hooks, keyed diffing, hydration and router, in about 700 lines with no dependencies.jongo/vdom.pyis the same tree model on the server.
Developing Jongo
python3 -m venv .venv && .venv/bin/pip install -e . pytest
.venv/bin/python -m pytest # needs `node` for the compiler parity tests
cd examples/todo && ../../.venv/bin/jongo dev
Status
Version 0.2 — 0.1 hardened over two stress-audit passes (see CHANGELOG.md). Known limits:
- SQLite only.
- No WebSockets yet.
- No classes in browser code.
- Components re-render their subtree without memoisation.
x__ne=v/.exclude(field=v)also match rows where the column isNULL(matching Python'sNone != v, not SQL's three-valued logic).- Browser (post-hydration) code inherits JavaScript's value model, so a few things differ from CPython (the server is always correct): integers past 2⁵³ lose precision and
str(2.0)shows"2"(one number type — format explicitly, e.g.f"{x:.2f}"); a dict keyed by ints iterates string keys;len("😀")counts UTF-16 units;(1,2) == [1,2]is true. Do exact numeric/big-integer work in a@serverfunction.
Bug reports and ideas are welcome.
License
Jongo is Copyright © 2026 Joshua Harty, and is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later) — see LICENSE. You are free to use, study, share and modify it, but any modified version you distribute or make available to users over a network must also be released, in full source form, under the AGPL. This keeps Jongo open and prevents it from being folded into closed, proprietary products. Versions 0.1.0–0.2.2 were released under the MIT License and remain available under those terms.
Metadata
Release files for jongo 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| jongo-0.3.0.tar.gz | 177.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| jongo-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 325.5 kB
Release files / jongo-0.3.0.tar.gz
| Download URL | jongo-0.3.0.tar.gz |
|---|---|
| Size | 177.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cf91f06df60385738aaa05d908cc4d88814b3fb2fd47e06bae1515eb9f283ecf
|
|
BLAKE2b-256 checksum How to use checksums |
8c478daf1aae9381e315d9198dffa1fae6a9f4750630c52c2985c9ee70c0a389
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency logRelease files / jongo-0.3.0-py3-none-any.whl
| Download URL | jongo-0.3.0-py3-none-any.whl |
|---|---|
| Size | 148.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f849f5321d7a8846c8b5f4cdf9efc5750ffeefdd8e8d5e5439f8d503f98e6bbc
|
|
BLAKE2b-256 checksum How to use checksums |
0fca4fe8fa8043065f011a1ab76db091784b798e74ded4a41b5a05f7784a9653
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency log