What kastor is, and is not, for a Django team
The Django ORM is part of Django, written in Python, and it works best inside a Django project. kastor is written in TypeScript and runs in Node.js, Bun, Deno and edge runtimes such as Cloudflare Workers. There is no Python package, and nothing in this guide makes kastor run inside Django.
So the useful question for someone coming from the Django ORM is narrower: when you write TypeScript, what does the familiar workflow look like? Teams end up here on one of two paths:
- Path A: a service moves to TypeScript. A real time feature, an edge API or a whole backend is rewritten in Node.js or Bun, and the team wants models, relations, QuerySet style filtering and migrations it recognizes.
- Path B: TypeScript next to Django. Django and its admin keep running, and a TypeScript API (often a Next.js or mobile backend) uses the same PostgreSQL or MySQL database.
Step 1: pull the schema Django built
npm install kastor-orm pg
npx kastor-orm init --db postgres
npx kastor db pull --out src/db/schema.tsdb pull reads the tables manage.py migrate created, including the many to many join tables Django generates, and writes table() definitions plus relations guessed from foreign keys. Django names tables app_model; rename the exports to something readable and keep the table names as they are.
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
class Post(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts")
title = models.CharField(max_length=200)
body = models.TextField(blank=True, null=True)
published = models.BooleanField(default=False)
views = models.IntegerField(default=0)
tags = models.ManyToManyField(Tag, related_name="posts")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [models.Index(fields=["published", "created_at"])]import { table, t, index, one, many } from 'kastor-orm'
export const users = table('auth_user', {
id: t.id(),
username: t.string(150).unique(),
email: t.string(254),
password: t.string(128),
isActive: t.boolean().map('is_active'),
dateJoined: t.datetime().map('date_joined').defaultNow(),
})
export const tags = table('blog_tag', {
id: t.bigId(),
name: t.string(50).unique(),
})
export const posts = table('blog_post', {
id: t.bigId(),
authorId: t.int().map('author_id').references(() => users.id, { onDelete: 'cascade' }),
title: t.string(200),
body: t.text().nullable(),
published: t.boolean().default(false),
views: t.int().default(0),
createdAt: t.datetime().map('created_at').defaultNow(),
updatedAt: t.datetime().map('updated_at').updatedAt(),
}, { indexes: (p) => [index(p.published, p.createdAt)] })
// The join table Django created for ManyToManyField.
export const postTags = table('blog_post_tags', {
id: t.bigId(),
postId: t.bigint().map('post_id').references(() => posts.id, { onDelete: 'cascade' }),
tagId: t.bigint().map('tag_id').references(() => tags.id, { onDelete: 'cascade' }),
})
export const schema = { users, tags, posts, postTags }
export const relations = ({ users, tags, posts, postTags }: typeof schema) => ({
users: { posts: many(posts) },
posts: {
author: one(users, { fields: ['authorId'], references: ['id'] }),
tags: many(tags, { through: postTags }),
},
tags: { posts: many(posts, { through: postTags }) },
})Django enforces on_delete in Python, so its foreign keys in the database are plain constraints without a cascade rule. Check what db pull reports: if the database has no ON DELETE CASCADE, keep the reference without onDelete in kastor too, and delete children explicitly, which is what Django does for you.
Step 2: pick the migration owner
Django records applied migrations in django_migrations; kastor uses _kastor_migrations. They never touch each other's table.
- Django stays in charge (path B, usually). Keep using
makemigrationsandmigrate, then runnpx kastor db pulland commit the refreshed TypeScript schema. Do not runkastor migrate devagainst that database. - kastor takes over (path A). Baseline once, then change the schema in TypeScript:
npx kastor migrate baseline --name init
npx kastor migrate dev --name add_slug_to_posts
npx kastor migrate deployStep 3: QuerySets to kastor calls
posts = (
Post.objects.filter(published=True, tags__name="python")
.exclude(title__icontains="draft")
.select_related("author")
.prefetch_related("tags")
.annotate(n_tags=Count("tags"))
.order_by("-created_at")[:20]
)
Post.objects.filter(pk=post_id).update(views=F("views") + 1)import { db } from '../db'
export const latest = () =>
db.posts.findMany({
where: {
published: true,
tags: { some: { name: 'python' } },
NOT: { title: { contains: 'draft', mode: 'insensitive' } },
},
include: {
author: { select: { username: true } },
tags: true,
_count: { select: { tags: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
})
export const bump = (postId: bigint) =>
db.posts.update({ where: { id: postId }, data: { views: { increment: 1 } } })| Django ORM | kastor |
|---|---|
class Post(models.Model) | table('blog_post', ...) |
CharField(max_length=n), TextField | t.string(n), t.text() |
null=True | .nullable() |
auto_now_add, auto_now | defaultNow(), updatedAt() |
ForeignKey, OneToOneField | .references() with one() |
related_name="posts" | many(posts) on the other side |
ManyToManyField | many(tags, { through: postTags }) |
filter(a=1), exclude(...) | where: { a: 1 }, NOT |
__in, __gte, __isnull=True | in, gte, null |
__icontains, __startswith | contains with mode, startsWith |
Q(a) | Q(b), ~Q(a) | OR: [a, b], NOT: a |
tags__name="x" | tags: { some: { name: 'x' } } |
select_related, prefetch_related | include |
values('id', 'title'), only() | select |
annotate(Count(...)), aggregate(Sum(...)) | _count, groupBy, aggregate |
F('views') + 1 | { increment: 1 } |
get_or_create, update_or_create | upsert |
bulk_create | createMany |
[:20], Paginator | take, skip, findPage |
transaction.atomic() | db.$transaction(async (tx) => ...) |
Model.objects.raw(sql), connection.cursor() | db.$sql`...`, db.$execute`...` |
makemigrations and migrate | npx kastor migrate dev and migrate deploy |
loaddata, factory_boy | npx kastor db seed, factory() |
| Django admin for browsing data | npx kastor studio |
QuerySets are lazy and so are kastor queries: nothing runs until you await. A difference to keep in mind is that a kastor result never fetches more on attribute access. If you did not include a relation, it is not on the object, and the compiler tells you before the code runs. That rules out the N+1 pattern Django leaves to select_related discipline.
Path B in practice: sharing the database with Django
- Sessions stay in Django. Django signs session cookies and keeps sessions in its own store. Rather than decoding them, verify credentials or tokens from TypeScript.
- Password hashes. Django's default hasher writes
pbkdf2_sha256$iterations$salt$hash, which Node can check with its built in crypto module. - Signals.
post_savereceivers do not fire for TypeScript writes. Move logic that must run for every write into the database (constraints, triggers) or into one service both sides call. - Time zones. With
USE_TZ = True, Django stores UTC. kastor returnsDateobjects, which are UTC instants as well.
import { pbkdf2Sync, timingSafeEqual } from 'node:crypto'
import { db } from '../db'
// Checks a password against Django's default pbkdf2_sha256 hasher.
export async function login(username: string, password: string) {
const user = await db.users.findUnique({ where: { username } })
if (!user || !user.isActive) return null
const [algorithm, iterations, salt, expected] = user.password.split('$')
if (algorithm !== 'pbkdf2_sha256' || !iterations || !salt || !expected) return null
const actual = pbkdf2Sync(password, salt, Number(iterations), 32, 'sha256').toString('base64')
const ok = actual.length === expected.length && timingSafeEqual(Buffer.from(actual), Buffer.from(expected))
return ok ? user : null
}Pitfalls to check
- Big auto ids. Recent Django projects use
BigAutoField, so ids aret.bigId()and arrive asbigint. Older tables andauth_usermay still use 32 bit ids; follow whatdb pullreports. - Generic relations.
GenericForeignKeystores a content type id and an object id without a database foreign key. Query those with filters on both columns or with$sql. - Choices.
choices=lives only in Python. If you want the same guarantee in TypeScript, declare the column ast.enum([...]); that also adds a check when kastor owns migrations. - Blank strings. Django stores empty strings for
CharField(blank=True)withoutnull=True. Filter on'', notnull. - Default ordering.
Meta.orderingapplies implicitly in Django. kastor never orders implicitly, so addorderBywhere order matters.
The Fastify and Hono guides are good next steps for an API that runs next to Django, and the migration hub lists every other path.
Questions people ask
Q1Can kastor be used from Python or inside Django?
No. kastor runs in JavaScript runtimes only: Node.js, Bun, Deno and edge platforms. Django keeps its own ORM. kastor is for TypeScript code, either a service you move out of Django or an API that runs next to it on the same database.
Q2What is the kastor version of select_related and prefetch_related?
Both are include. Post.objects.select_related('author').prefetch_related('tags') becomes db.posts.findMany({ include: { author: true, tags: true } }), and kastor loads the whole tree in one statement per call.
Q3How do Q objects and double underscore lookups translate?
Lookups become nested filter objects: title__icontains is title: { contains, mode: 'insensitive' }, author__name is a filter on the author relation, and Q(a) | Q(b) is OR: [a, b].
Q4Can Django and a Node.js API share one PostgreSQL database?
Yes. Pull the schema with npx kastor db pull, keep one side in charge of migrations, and keep Django conventions such as the app_model table names and timestamp columns. Verify users through password hashes or tokens rather than Django session cookies.
Q5What replaces fixtures and factory_boy?
A seed file with defineSeed, run by npx kastor db seed, and the factory() helper for test data typed from the schema.