Modeling Relational Data in Appwrite Without Losing Your Mind
Appwrite's relationship attributes bring referential integrity and automatic expansion to a document store — but expansion depth caps, dual permission checks, and an opaque junction collection change how you should model your data.
11 Jun 2026, 12:15 UTC

The problem: documents that know about each other
Appwrite's database is a document store, and document stores have a well-known blind spot: data that references other data. If you're building a blog, a task manager, or anything with users and the things they own, you quickly hit the question every document-database developer faces — do I embed, or do I reference?
Embedding (copying related data into the parent document) is fast to read but painful to update. Referencing by ID keeps writes clean but forces your application to stitch everything back together and enforce integrity itself. Appwrite's relationship attributes are a deliberate middle path: native one-to-one, one-to-many, and many-to-many relationships defined at the collection level, with referential integrity and automatic expansion handled by the platform.
The thesis of this post: relationship attributes are the right default for most Appwrite data models, but only if you understand how expansion depth, permissions, and the hidden junction collection actually behave — because each of those has a sharp edge.
What a relationship attribute actually is
When you add a relationship attribute to a collection (say, an author relationship on a posts collection pointing at users), Appwrite stores the related document's ID in a system-managed attribute. You don't manage foreign keys yourself, and you can't leave a dangling reference pointing at a document that violates the relationship's constraints.
When you fetch documents, you can ask Appwrite to expand the relationship — that is, fetch the related documents inline in the same response. Expansion depth is configurable but capped: the default depth is 1 and the maximum is 3. That cap exists to stop a single query from pulling an unbounded graph of documents. If you need deeper traversal, you make additional queries — which is usually a sign you should reconsider the model anyway.
For many-to-many relationships (students and courses, tags and articles), Appwrite creates a hidden junction collection behind the scenes. You query either side through the relationship attribute name, and you never touch the junction directly. That convenience comes with a real limitation, covered below.
A worked example: posts and comments
Say you have a posts collection and a comments collection. You create a one-to-many relationship attribute named comments on posts. To fetch a post with its comments using the Web SDK (Appwrite 1.5+), you'd run something like this in your client application:
import { Client, Databases, Query } from "appwrite";
const client = new Client()
.setEndpoint("https://cloud.appwrite.io/v1")
.setProject("");
const databases = new Databases(client);
const post = await databases.getDocument(
"",
"posts",
"",
[Query.select(["*", "comments.*"])]
);
The exact query syntax for requesting expansion has evolved across SDK versions, so check the current SDK reference for your version — older releases used different mechanisms than newer ones. The behavior to verify is simple: the response contains the post document with a comments array of full comment documents, not just IDs.
Two things worth checking after your first query: the payload size (expansion inflates it fast) and whether every related document you expected actually came back. If some are missing, permissions are the first suspect.
Permissions are checked on both sides — use that
This is the part people get wrong. Appwrite evaluates document-level permissions on the parent and on each related document. A user only sees an expanded related document if they have read access to it. That's not an inconvenience — it's a feature. It means you can model, for example, a project with a mix of public and private tasks, and a single query returns exactly what the requesting user is allowed to see, with no filtering logic in your app.
The cost is real, though: expanding a one-to-many relationship across 100 parent documents can mean 100+ additional permission evaluations per query. Team-based permissions (like team:/read) are evaluated against the user's membership at query time, which adds further work. If a list endpoint feels slow, relationship expansion plus permission checks is the first place to profile.
The trade-offs you should accept up front
Three limitations deserve to be decision inputs, not surprises:
- No cascading deletes. Deleting a parent document does not delete or clean up related documents. If you delete a post, its comments remain. The standard fix is an Appwrite Function triggered on document delete that removes the children — Functions can subscribe to document create/update/delete events, so this is a supported pattern, not a hack.
- The many-to-many junction is opaque. You cannot query or index the hidden junction collection directly. If your link needs its own attributes — say, a
roleon a team membership, or anenrolledAtdate on a course enrollment — a native many-to-many relationship won't hold them. Model the junction as a real collection with two one-to-many relationships instead. - Deep expansion is expensive and capped. Depth beyond 1 multiplies response size and latency, and 3 is the hard ceiling on default configurations. Filtering or sorting by a field on a related document also requires an appropriate index on the parent collection — check the console's index requirements for your query shape before assuming it will be fast.
So when should you denormalize instead? When the related data is small, read constantly, and changes rarely — an author's display name copied onto a post, for instance. You accept stale-copy risk in exchange for zero expansion cost. For everything else, relationship attributes give you cleaner writes and enforced integrity.
How to validate your model before committing
Before you build on a relationship-heavy schema, run a 30-minute spike: create a test project (Appwrite Cloud or self-hosted 1.5+), define two collections with a one-to-many relationship, insert a few hundred parent documents, and query them with and without expansion. Measure response time and payload size in both cases. Then create a second user, restrict read permissions on some related documents, and confirm the expansion silently omits what that user can't read. Those two experiments will tell you more about how your app will behave than any amount of documentation reading — and they'll surface permission surprises while they're still cheap to fix.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.