kastor0.1.0
Get started

GUIDE 20From Django ORM

Coming from the Django ORM

kastor is a TypeScript ORM for Node.js, Bun, Deno and edge runtimes. It does not run in Python, and it does not replace Django inside a Django project. It does give Django developers a familiar model of the world in TypeScript, and it can work on the same database as a running Django app.

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

Terminal
npm install kastor-orm pg
npx kastor-orm init --db postgres
npx kastor db pull --out src/db/schema.ts

db 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.

blog/models.py
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"])]
src/db/schema.ts
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 makemigrations and migrate, then run npx kastor db pull and commit the refreshed TypeScript schema. Do not run kastor migrate dev against that database.
  • kastor takes over (path A). Baseline once, then change the schema in TypeScript:
Terminal
npx kastor migrate baseline --name init
npx kastor migrate dev --name add_slug_to_posts
npx kastor migrate deploy

Step 3: QuerySets to kastor calls

blog/views.py
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)
src/posts/queries.ts
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 ORMkastor
class Post(models.Model)table('blog_post', ...)
CharField(max_length=n), TextFieldt.string(n), t.text()
null=True.nullable()
auto_now_add, auto_nowdefaultNow(), updatedAt()
ForeignKey, OneToOneField.references() with one()
related_name="posts"many(posts) on the other side
ManyToManyFieldmany(tags, { through: postTags })
filter(a=1), exclude(...)where: { a: 1 }, NOT
__in, __gte, __isnull=Truein, gte, null
__icontains, __startswithcontains with mode, startsWith
Q(a) | Q(b), ~Q(a)OR: [a, b], NOT: a
tags__name="x"tags: { some: { name: 'x' } }
select_related, prefetch_relatedinclude
values('id', 'title'), only()select
annotate(Count(...)), aggregate(Sum(...))_count, groupBy, aggregate
F('views') + 1{ increment: 1 }
get_or_create, update_or_createupsert
bulk_createcreateMany
[:20], Paginatortake, skip, findPage
transaction.atomic()db.$transaction(async (tx) => ...)
Model.objects.raw(sql), connection.cursor()db.$sql`...`, db.$execute`...`
makemigrations and migratenpx kastor migrate dev and migrate deploy
loaddata, factory_boynpx kastor db seed, factory()
Django admin for browsing datanpx 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_save receivers 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 returns Date objects, which are UTC instants as well.
src/auth/django-password.ts
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 are t.bigId() and arrive as bigint. Older tables and auth_user may still use 32 bit ids; follow what db pull reports.
  • Generic relations. GenericForeignKey stores 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 as t.enum([...]); that also adds a check when kastor owns migrations.
  • Blank strings. Django stores empty strings for CharField(blank=True) without null=True. Filter on '', not null.
  • Default ordering. Meta.ordering applies implicitly in Django. kastor never orders implicitly, so add orderBy where 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.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.