Skip to content

Relations

Define relationships between models using ForeignKey, ManyToMany, and OneToOne fields.


Overview

Aksara supports three types of relationships:

Type Relationship Example
ForeignKey Many-to-One Posts have one Author
OneToOne One-to-One User has one Profile
ManyToMany Many-to-Many Posts have many Tags

All relationships support:

  • Forward access — Access related objects from the defining model
  • Reverse access — Access back from the related model
  • AI metadata — Relationship descriptions for LLMs

ForeignKey (Many-to-One)

A ForeignKey creates a many-to-one relationship where multiple objects can reference a single related object.

Definition

from aksara import Model, fields, CASCADE

class Author(Model):
    name = fields.String(max_length=100)
    email = fields.Email(unique=True)

class Post(Model):
    title = fields.String(max_length=200)
    content = fields.Text()
    author = fields.ForeignKey(
        Author,                    # or "Author" as string
        on_delete=CASCADE,
        related_name="posts",      # Reverse accessor name
    )

Options

Option Type Default Description
to str or type Required Target model
on_delete str CASCADE Delete behavior
related_name str {model}_set Name for reverse relation
nullable bool False Allow NULL values

Forward Access

Access the related object from the child:

# Get a post
post = await Post.objects.get(id=post_id)

# Access the author (async call for lazy loading)
author = await post.author
print(author.name)

# Or access the raw FK value (synchronous)
author_id = post.author_id

Reverse Access

Access related objects from the parent:

# Get an author
author = await Author.objects.get(id=author_id)

# Access all posts by this author
posts = await author.posts.all()

# Filter posts
recent_posts = await author.posts.filter(created_at__gt=last_week)

# Count posts
post_count = await author.posts.count()

Creating with ForeignKey

# Method 1: Pass the related object
author = await Author.objects.get(email="jane@example.com")
post = await Post.objects.create(
    title="My Post",
    content="Content here",
    author=author,
)

# Method 2: Pass the ID directly
post = await Post.objects.create(
    title="My Post",
    content="Content here",
    author_id=author.id,
)

on_delete Options

When the referenced object is deleted, what happens to objects that reference it?

CASCADE

Delete the referencing objects too.

class Post(Model):
    author = fields.ForeignKey(Author, on_delete=CASCADE)

# When author is deleted, all their posts are deleted
await author.delete()  # Posts automatically deleted

SET_NULL

Set the foreign key to NULL (requires nullable=True).

class Post(Model):
    category = fields.ForeignKey(
        Category,
        on_delete=SET_NULL,
        nullable=True,
    )

# When category is deleted, posts remain but category becomes NULL
await category.delete()  # post.category becomes None

RESTRICT / PROTECT

Prevent deletion if related objects exist.

class Post(Model):
    author = fields.ForeignKey(Author, on_delete=RESTRICT)

# This raises an error if the author has posts
await author.delete()  # Raises ForeignKeyConstraintError

Import on_delete Constants

from aksara import CASCADE, SET_NULL, RESTRICT, PROTECT
# or
from aksara.fields import CASCADE, SET_NULL, RESTRICT, PROTECT
# or use the enum
from aksara.relations import OnDelete
author = fields.ForeignKey(User, on_delete=OnDelete.CASCADE)

OneToOne

A OneToOne relationship is like a ForeignKey but enforces uniqueness — each object can only be related to one other object.

Definition

class User(Model):
    email = fields.Email(unique=True)

class UserProfile(Model):
    user = fields.OneToOne(
        User,
        on_delete=CASCADE,
        related_name="profile",
    )
    bio = fields.Text(nullable=True)
    avatar_url = fields.URL(nullable=True)

Forward Access

profile = await UserProfile.objects.get(id=profile_id)
user = await profile.user
print(user.email)

Reverse Access

user = await User.objects.get(id=user_id)
profile = await user.profile()  # Note: callable, not manager
print(profile.bio)

OneToOne Reverse is a Single Object

Unlike ForeignKey's reverse which returns a manager (.all(), .filter()), OneToOne reverse returns a single object (or raises DoesNotExist).

When to Use OneToOne

  • User profiles — Separate profile data from auth data
  • Optional extensions — Data that doesn't apply to all records
  • Large fields — Separate rarely-accessed large fields

ManyToMany

A ManyToMany relationship allows multiple objects on both sides of the relationship.

Definition

class Tag(Model):
    name = fields.String(max_length=50, unique=True)

class Post(Model):
    title = fields.String(max_length=200)
    tags = fields.ManyToMany(
        Tag,
        related_name="posts",
    )

Options

Option Type Default Description
to str or type Required Target model
related_name str {model}_set Name for reverse relation
through str Auto-generated Custom junction table

Forward Access

post = await Post.objects.get(id=post_id)

# Get all tags
tags = await post.tags.all()

# Add tags
await post.tags.add(tag1, tag2)

# Remove tags
await post.tags.remove(tag1)

# Clear all tags
await post.tags.clear()

# Set tags (replaces existing)
await post.tags.set([tag1, tag2, tag3])

# Check membership
has_tag = await post.tags.contains(tag)

Reverse Access

tag = await Tag.objects.get(name="python")

# Get all posts with this tag
posts = await tag.posts.all()

# Filter
recent = await tag.posts.filter(created_at__gt=last_week)

Junction Table

Aksara automatically creates a junction table:

-- Auto-generated for Post.tags -> Tag
CREATE TABLE post_tags (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    UNIQUE (post_id, tag_id)
);

Custom Through Model

For additional data on the relationship:

class PostTag(Model):
    """Junction table with extra fields."""
    post = fields.ForeignKey(Post, on_delete=CASCADE)
    tag = fields.ForeignKey(Tag, on_delete=CASCADE)
    added_at = fields.DateTime(auto_now_add=True)
    added_by = fields.ForeignKey(User, on_delete=SET_NULL, nullable=True)

    class Meta:
        unique_together = [("post", "tag")]

class Post(Model):
    title = fields.String(max_length=200)
    tags = fields.ManyToMany(
        Tag,
        through="PostTag",  # Use custom junction
        related_name="posts",
    )

String References

Use string references for:

  • Forward references — Model defined later in the file
  • Circular references — Models that reference each other
class Author(Model):
    name = fields.String(max_length=100)
    # Reference Post that's defined below
    favorite_post = fields.ForeignKey("Post", nullable=True, on_delete=SET_NULL)

class Post(Model):
    title = fields.String(max_length=200)
    author = fields.ForeignKey("Author", on_delete=CASCADE)

Self-Referential Relations

Models can reference themselves:

class Category(Model):
    name = fields.String(max_length=100)
    parent = fields.ForeignKey(
        "self",  # or "Category"
        on_delete=CASCADE,
        nullable=True,
        related_name="children",
    )

# Usage
parent = await Category.objects.create(name="Electronics")
child = await Category.objects.create(name="Phones", parent=parent)

# Access children
phones = await parent.children.all()

# Access parent
electronics = await child.parent

Querying Relations

# Posts by a specific author
posts = await Post.objects.filter(author=author)

# Posts by author email
posts = await Post.objects.filter(author__email="jane@example.com")

# Posts with a specific tag (M2M)
posts = await Post.objects.filter(tags__name="python")

Avoid N+1 queries by loading related objects in one query:

# Without select_related: N+1 queries
posts = await Post.objects.all()
for post in posts:
    author = await post.author  # Query per post!

# With select_related: Single query
posts = await Post.objects.select_related("author").all()
for post in posts:
    print(post.author.name)  # Already loaded
# Efficient M2M loading
posts = await Post.objects.prefetch_related("tags").all()
for post in posts:
    for tag in post.tags:
        print(tag.name)

Complete Example

from aksara import Model, fields, CASCADE, SET_NULL

class User(Model):
    email = fields.Email(unique=True)
    name = fields.String(max_length=100)

class Category(Model):
    name = fields.String(max_length=50)
    slug = fields.String(max_length=50, unique=True)
    parent = fields.ForeignKey(
        "self",
        on_delete=CASCADE,
        nullable=True,
        related_name="children",
    )

class Tag(Model):
    name = fields.String(max_length=30, unique=True)
    slug = fields.String(max_length=30, unique=True)

class Post(Model):
    title = fields.String(max_length=200)
    content = fields.Text()
    published = fields.Boolean(default=False)

    # Many-to-one: Many posts per author
    author = fields.ForeignKey(
        User,
        on_delete=CASCADE,
        related_name="posts",
    )

    # Many-to-one: Many posts per category (optional)
    category = fields.ForeignKey(
        Category,
        on_delete=SET_NULL,
        nullable=True,
        related_name="posts",
    )

    # Many-to-many: Posts have multiple tags
    tags = fields.ManyToMany(
        Tag,
        related_name="posts",
    )

    created_at = fields.DateTime(auto_now_add=True)

class UserProfile(Model):
    # One-to-one: Each user has one profile
    user = fields.OneToOne(
        User,
        on_delete=CASCADE,
        related_name="profile",
    )
    bio = fields.Text(nullable=True)
    website = fields.URL(nullable=True)


# Usage examples
async def demo():
    # Create user with profile
    user = await User.objects.create(email="jane@example.com", name="Jane")
    profile = await UserProfile.objects.create(user=user, bio="Tech writer")

    # Create category hierarchy
    tech = await Category.objects.create(name="Technology", slug="tech")
    python = await Category.objects.create(name="Python", slug="python", parent=tech)

    # Create tags
    tutorial = await Tag.objects.create(name="Tutorial", slug="tutorial")
    beginner = await Tag.objects.create(name="Beginner", slug="beginner")

    # Create post with relations
    post = await Post.objects.create(
        title="Getting Started with Python",
        content="Learn Python basics...",
        author=user,
        category=python,
    )

    # Add tags
    await post.tags.add(tutorial, beginner)

    # Query examples
    jane_posts = await user.posts.all()
    tech_posts = await tech.posts.all()  # Including child categories
    tutorial_posts = await Post.objects.filter(tags__name="Tutorial")

    # Efficient loading
    posts = await Post.objects.select_related("author", "category").all()

Best Practices

# Good
author = fields.ForeignKey(User, related_name="authored_posts")
editor = fields.ForeignKey(User, related_name="edited_posts")

# Confusing
author = fields.ForeignKey(User, related_name="posts")
editor = fields.ForeignKey(User, related_name="posts2")  # ❌

Choose on_delete Carefully

Scenario Recommendation
Comments on a Post CASCADE — Delete comments with post
Posts in a Category SET_NULL — Keep posts, clear category
User's Auth Tokens CASCADE — Delete tokens with user
Order's Customer PROTECT — Don't delete customers with orders
# Always use when accessing related objects
posts = await Post.objects.select_related("author").all()

Consider Nullable for Optional Relations

# Optional category
category = fields.ForeignKey(
    Category,
    on_delete=SET_NULL,
    nullable=True,  # Posts can exist without a category
)