Before I dive into the migration itself, I need to take a moment to explain why leaving Gel was so difficult. This isn't just a technical post. It's an appreciation letter to a technology that genuinely changed how I think about databases.
When I wrote my first post about the forced migration, some readers asked: "Why not just use Postgres? Why were you using this obscure database in the first place?"
Fair question. Let me show you.
EdgeQL: Queries That Actually Make Sense
The first time I wrote an EdgeQL query, something clicked. After years of writing SQL JOINs, subqueries, and wrestling with ORMs, EdgeQL felt like someone had finally asked: "What if querying data was actually intuitive?"
Here's what I mean. Let's say you want to fetch a user along with their projects, each project's metadata, and all associated tasks. In traditional SQL, you'd write something involving multiple JOINs, careful column selection, and probably some post-processing to nest the results properly.
That's it. No JOINs. No aliasing. No flattening and re-nesting. You describe the shape of the data you want, and EdgeQL figures out how to get it.
The result comes back exactly as you specified: a user object with a nested array of projects, each containing nested metadata and tasks. The query language matches the mental model of your data.
This composability extends to filtering and ordering too:
select User { name, # Only include active projects, ordered by creation date projects: { name, taskCount := count(.tasks), # Only incomplete tasks, ordered by due date tasks: { title, dueDate } filter not .completed order by .dueDate } filter .status = 'active' order by .createdAt desc}
Each level of nesting can have its own filters and ordering. Try doing that cleanly in SQL.
The learning curve for EdgeQL is real. It's not SQL. The syntax is different, the operators are different, and you have to unlearn some habits. But once it clicks, you start wondering why all databases don't work this way.
Declarative Schema: Your Data Model in Plain English
Gel uses SDL (Schema Definition Language) for defining your data model. If you've used GraphQL, the syntax will feel familiar. But SDL goes much further.
Here's a simplified example of how you might model a project management system:
module default { scalar type Status extending enum<draft, active, archived>; type User { required name: str; required email: str { constraint exclusive; }; # Backlink: all projects owned by this user multi projects := .<owner[is Project]; # Computed field
Let me highlight what's special here:
Computed fields live in your schema. See projectCount, progress, and urlSlug? Those are computed at query time, but defined declaratively. No need to remember to calculate them in your application code. The database handles it.
Backlinks are first-class citizens. The multi projects := .<owner[is Project] syntax creates a computed backlink. Any project that has this user as its owner automatically appears in the user's projects field. No manual syncing required.
Nested types with lifecycle management. The on source delete delete target clause means when you delete a project, its metadata gets deleted too. Cascade deletes, defined right in the schema.
Automatic timestamps. The rewrite insert using and rewrite update using clauses handle created and updated timestamps automatically. No triggers, no application code.
Migrations are generated for you. When you change your schema, Gel compares it to the current database state and generates a migration. No writing migration files by hand. Change the schema, run the migration tool, done.
This declarative approach meant my schema file was always the source of truth. I could read it and understand my entire data model, including computed fields, relationships, and lifecycle rules.
Built-in Authentication: One Less Thing to Worry About
Authentication is one of those things that every application needs, but nobody wants to build from scratch. Gel came with a built-in auth extension that integrated seamlessly with Next.js.
Email/password authentication, OAuth providers (Google, GitHub, etc.), password reset flows, email verification. All handled by Gel's auth extension.
But the real magic was the global currentUser pattern:
global currentUser := ( assert_single(( select User filter .identity = global ext::auth::ClientTokenIdentity )));
This global variable was available in every query. When a user made a request, Gel automatically knew who they were based on their session token. No need to pass user IDs around. No need to verify tokens manually.
In your queries, you could simply write:
# Get the current user's projectsselect global currentUser.projects { name, status, taskCount}
The authentication context was baked into the database layer. It felt like the right level of abstraction.
Access Policies: Security as Code
This is where Gel really shined. Access policies let you define row-level security directly in your schema:
type Project { required name: str; required owner: User; required published: bool { default := false; }; # ... other fields ... access policy adminHasFullAccess allow all using (global currentUser.role ?= Role.admin); access policy ownerHasFullAccess allow all using (.owner ?= global currentUser); access policy publishedProjectsAreVisible
Let me break down what's happening here:
Admins can do anything. If the current user has an admin role, all operations are allowed.
Owners have full access to their own projects. The .owner ?= global currentUser check ensures users can only modify their own data.
Published projects are publicly visible. Anyone can read projects where published is true, even without authentication.
Business logic enforcement. The deny insert policy prevents users from creating projects if they've hit their plan limit.
These policies are enforced at the database level. You can't accidentally bypass them in your application code. Forget to add an authorization check in one of your API routes? Doesn't matter. The database won't let unauthorized access through.
This was a massive mental load reduction. Instead of scattering authorization checks throughout my codebase and hoping I didn't miss any, I defined the rules once in my schema. The database became the single source of truth for both data structure and access control.
The Learning Curve Was Worth It
I won't pretend Gel was easy to pick up. EdgeQL is its own language. The SDL syntax has its quirks. The documentation, while good, couldn't cover every edge case I encountered.
My first few weeks were filled with moments of "how do I do X in EdgeQL?" that would have been trivial in SQL. Aggregations work differently. Subqueries have different semantics. Even basic things like optional field handling took some getting used to (the ?? operator became my friend).
But here's the thing: every hour I spent learning Gel paid dividends in development speed later. Once I understood the mental model, I could write complex queries in minutes that would have taken much longer in SQL. The schema-as-truth approach meant fewer bugs from data model misunderstandings. The access policies meant I could sleep at night knowing my authorization logic was airtight.
The learning curve was an investment, and it paid off.
So Why Was It Hard to Leave?
When I got that email on December 2nd, I didn't just lose a database provider. I lost:
Query composability that made fetching nested data trivial
Computed fields that lived in my schema, not scattered across my codebase
Automatic migrations that saved hours of tedious work
Built-in auth that just worked with my Next.js app
Access policies that made authorization declarative and bulletproof
A mental model that felt like the future of how databases should work
Finding a replacement that captured even some of this magic was going to be a challenge.
What Comes Next
In the next post, I'll walk through my evaluation process for finding Gel's replacement. Spoiler: I landed on Convex, but it wasn't an obvious choice. I had to think carefully about what I actually needed versus what I'd gotten used to.
Some of Gel's features had direct equivalents in Convex. Others required completely rethinking my approach. And a few things? I just had to accept I was going to lose them.
The migration wasn't just about moving data from one database to another. It was about translating an entire way of thinking about my backend into a different paradigm.
Let's see how that went.
Enjoyed this post?
Subscribe to get notified when I publish new articles. No spam, unsubscribe anytime.