K AVAILABLE
← BACK TO WRITING

WRITING

Why We Ditched Prisma for Go + sqlc: Cut Memory Footprint by 90% and Reclaimed Query Control

Why SQL-First code generation is outperforming ORM magic in high-performance microservices.

Published Jul 28, 20266 min readdecision
Why We Ditched Prisma for Go + sqlc Cover
THE TAKEAWAYBy shifting query compilation from runtime to build-time with sqlc and Go, we cut microservice memory footprint by 90% while keeping 100% compile-time type safety.

Overview

I spent the last two years building Node.js backends with NestJS and Prisma. On paper, it's the dream developer setup: auto-generated TypeScript types, smooth migrations, and an API so clean you almost forget you're talking to a SQL database.

Then we tried to scale down our infrastructure costs by co-locating five production microservices on a single GCP Compute Engine VM.

Suddenly, the hidden ORM tax became impossible to ignore. Each Node.js + Prisma container idle-started at 150MB–250MB of RAM. Queries that looked harmless in TypeScript generated complex, dynamic SQL strings under the hood, and debugging database bottlenecks required peeling back layers of abstraction.

When we migrated our API backend to Go (Golang), we made a deliberate architectural shift: we dropped ORMs entirely and adopted `sqlc`.

Here is a deep dive into how Prisma (Code-First ORM) compares to `sqlc` (SQL-First Code Generator), why runtime memory dropped by 90%, and how you should answer this trade-off in a System Design interview.

1. Code-First vs. SQL-First: The Philosophical Divide

Both tools solve the exact same core developer problem: eliminating manual object mapping (`rows.Scan()`) and preventing runtime SQL type errors.

However, their execution philosophies are completely inverted:

text
Prisma Approach:   DSL Schema (.prisma)  ──>  Dynamic SQL Generated at Runtime
sqlc Approach:     Pure SQL (.sql)        ──>  Static Go Code Generated at Compile Time

Prisma (Code-First Abstraction)

In Prisma, you model data in a .prisma file, run prisma generate, and query your database via JavaScript method chaining:

javascript
// You write TypeScript method chains:
const user = await prisma.user.findUnique({
  where: { email: 'alex@example.com' },
  include: { schedules: true }
});

At runtime, Prisma's query engine dynamically translates this call into a SQL string, dispatches it to PostgreSQL, converts row data into JSON, and parses it back into JavaScript objects.

sqlc (SQL-First Code Generation)

In sqlc, you write raw, idiomatic PostgreSQL queries inside standard .sql files:

sql
-- db/queries/users.sql
-- name: GetUserWithSchedules :many
SELECT u.id, u.email, u.name, s.id AS schedule_id, s.date, s.status
FROM users u
LEFT JOIN schedules s ON s.user_id = u.id
WHERE u.email = $1;

When you execute sqlc generate, sqlc parses your SQL AST (Abstract Syntax Tree) against your actual database schema and compiles pure, statically-typed Go functions:

go
// Generated Go function — zero runtime translation
users, err := queries.GetUserWithSchedules(ctx, "alex@example.com")

2. Under the Hood: Why Go + sqlc Uses 90% Less Memory

The difference in baseline memory consumption isn't just language overhead — it's architectural.

text
[ NestJS / Node.js ] ──> [ Rust Query Engine Binary ] ──> [ IPC / Socket ] ──> [ PostgreSQL ]
[ Go Application   ] ─────────────────────────────────────────────────────────> [ PostgreSQL (pgx) ]

The Prisma Runtime Layer

Prisma doesn't talk directly to PostgreSQL from Node.js. It bundles a compiled Rust query engine binary that runs alongside your Node process.

Every single database call executes an Inter-Process Communication (IPC) roundtrip:

  • Node.js serializes JavaScript AST options to JSON.
  • Sends JSON over an IPC socket to the Rust engine.
  • Rust engine compiles JSON into a SQL string and executes it on Postgres.
  • Rust engine receives DB rows, serializes them back to JSON over the socket.
  • Node.js parses JSON back into V8 JavaScript heap objects.

This multi-process serialization layer consumes CPU cycles and inflates idle memory to ~150MB+ per container.

The sqlc Compile-Time Layer

sqlc has zero runtime existence. It exists exclusively as a CLI tool during build step.

The generated Go code communicates directly with PostgreSQL over Go's native binary driver (jackc/pgx). There is no IPC socket, no dynamic SQL parser, and no reflection.

Result: Go API microservices start up in < 10 milliseconds and run at a baseline of ~15MB RAM.

The N+1 Query Trap

With traditional ORMs, clean-looking code frequently hides performance landmines:

javascript
// Looks harmless, but fires 101 roundtrips if not carefully audited
const users = await prisma.user.findMany();
for (const user of users) {
  const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
}

Native PostgreSQL Features Without Workarounds

When using an ORM, leveraging advanced SQL capabilities — like Common Table Expressions (CTEs), window functions, or lateral joins — requires dropping down into un-typed prisma.$queryRaw.

With sqlc, advanced PostgreSQL features are first-class citizens because you write the SQL:

sql
-- name: GetTopPerformers :many
WITH completed_tasks AS (
    SELECT user_id, COUNT(id) as total_done
    FROM time_blocks
    WHERE status = 'FINISHED'
    GROUP BY user_id
)
SELECT u.id, u.name, ct.total_done,
       RANK() OVER (ORDER BY ct.total_done DESC) as rank
FROM users u
JOIN completed_tasks ct ON ct.user_id = u.id
ORDER BY rank ASC
LIMIT $1;

sqlc parses the CTE and window function output automatically, generating a Go struct with typed Rank int64 fields.

4. System Design Interview Comparison

If asked in a System Design or Architecture interview: "How do you evaluate ORMs vs. SQL-First Generators for production microservices?"

Here is the key breakdown to cover:

  • Primary Stack — ORMs like Prisma are popular in Node.js, TypeScript, and Java. SQL-First generators like sqlc thrive in Go and Python.
  • Query Definition — ORMs abstract queries via JavaScript method chains. sqlc requires explicitly written .sql files.
  • Type-Safety Origin — Prisma derives types from a DSL schema (schema.prisma). sqlc compiles types directly from your raw SQL queries and DB migrations.
  • Memory Overhead — ORMs carry a heavy runtime footprint (~150MB–250MB per process). sqlc is ultra-light (~15MB per process).
  • SQL Index Optimization — ORM query optimization is limited by the query planner. sqlc gives you complete direct control over SQL execution and database indexes.
  • Best Use Cases — ORMs excel at rapid prototyping, internal dashboards, and simple CRUD. sqlc excels at high-density microservices, cloud cost optimization, and low-latency APIs.

The Takeaway

Switching from NestJS + Prisma to Go + sqlc delivered the holy grail of backend engineering: 100% compile-time type safety with zero runtime performance cost.

By shifting query compilation from runtime to build-time, we cut microservice memory footprint by 90%, enabling us to run multiple high-throughput production Go microservices behind Nginx on a cost-efficient GCP Compute Engine VM with massive headroom to spare.

TOPICS:
GoGolangPostgreSQLPrismasqlcNode.jsNestJSSystem DesignMicroservices