FraiseQL vs Prisma
Side-by-side feature and architecture comparison.
This guide walks through migrating a Prisma-based application to FraiseQL, including schema conversion, client migration, and performance optimization.
| Feature | Prisma | FraiseQL |
|---|---|---|
| API Type | ORM (type-safe queries) | GraphQL (API-first) |
| Databases | All major databases | PostgreSQL-first |
| Development SDKs | Prisma Client (1) | Python, TypeScript, Go SDKs |
| Real-time | Polling | Native subscriptions via WebSocket |
| Schema Management | Migrations + Prisma Schema | SQL views/functions + FraiseQL decorators |
| Federation | No | Yes (multi-database queries) |
| Query Language | Programmatic (.findMany()) | GraphQL (industry standard) |
| Performance | Good (cached queries) | Excellent (pre-compiled SQL views, batching) |
| Caching | Manual or plugins | Built-in federation-aware caching |
| Multi-database | Via relationships only | Native federation support |
Prisma generates a client library. FraiseQL serves a multi-transport API server — REST, GraphQL, and gRPC clients connect directly without a client library.
FraiseQL asks you to write views. In exchange:
include still produces one query per relation. FraiseQL builds JSONB nesting into the view, so every request is a single SQL query regardless of depth.findMany/findUnique variants you didn’t ask for..where() calls.The migration involves: creating v_* views for your Prisma models, writing fn_* functions for your mutations, and replacing Prisma Client calls with a standard GraphQL client.
Prisma users think in terms of models and relations. FraiseQL maps those same concepts to typed classes backed by SQL views. Here is the same User and Post model expressed in both:
model User { id String @id @default(cuid()) name String email String @unique posts Post[]}
model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String}import { Type, Field, ID } from 'fraiseql';
@Type()class User { @Field() id!: string; @Field() name!: string; @Field() email!: string; @Field() posts!: Post[];}
@Type()class Post { @Field() id!: string; @Field() title!: string; @Field() content!: string; @Field() author!: User;}@fraiseql.typeclass User: id: ID name: str email: str posts: list['Post']
@fraiseql.typeclass Post: id: ID title: str content: str author: Userimport "github.com/fraiseql/fraiseql-go"
type User struct { ID fraiseql.ID `fraiseql:"id"` Name string `fraiseql:"name"` Email string `fraiseql:"email"` Posts []Post `fraiseql:"posts"`}
type Post struct { ID fraiseql.ID `fraiseql:"id"` Title string `fraiseql:"title"` Content string `fraiseql:"content"` Author User `fraiseql:"author"`}The relationship (posts, author) is backed by a SQL JOIN inside your v_user and v_post views. FraiseQL never generates SQL at runtime — you write the view once, and all queries against it are pre-compiled into the Rust binary.
const users = await prisma.user.findMany({ where: { email: { endsWith: "@example.com" } }, include: { posts: true }, orderBy: { createdAt: "desc" }, take: 10});query GetUsers { users( email_endsWith: "@example.com" limit: 10 orderBy: "createdAt DESC" ) { id email posts { id title } }}const user = await prisma.user.create({ data: { email: "alice@example.com", name: "Alice", posts: { create: [ { title: "First Post", content: "..." } ] } }, include: { posts: true }});mutation CreateUser { createUser(input: { email: "alice@example.com" name: "Alice" posts: [ { title: "First Post", content: "..." } ] }) { id email posts { id title } }}model User { id Int @id @default(autoincrement()) email String @unique posts Post[]}
model Post { id Int @id @default(autoincrement()) title String content String userId Int user User @relation(fields: [userId], references: [id])}@fraiseql.typeclass User: id: ID email: str posts: list['Post']
@fraiseql.typeclass Post: id: ID title: str content: str user_id: ID user: UserAudit Prisma Schema (Day 1)
Review your current Prisma schema and plan the FraiseQL structure.
# Review your current Prisma schemacat prisma/schema.prisma
# Export schema informationnpx prisma introspect # Generate schema from databaseCreate a mapping document:
Prisma Model FraiseQL TypeUser -> User (type)Post -> Post (type)Comment -> Comment (type)
Prisma Query FraiseQL QueryfindMany -> list[Type] queryfindUnique -> single Type queryfindFirst -> first(n) with limitSet Up FraiseQL Project
Initialize the FraiseQL binary and configure your database connection.
# Initialize FraiseQLfraiseql init fraiseql-api
# Create fraiseql.tomlcat > fraiseql.toml << 'EOF'[database]url = "${DATABASE_URL}"
[server]port = 8080EOF
# JWT authentication is configured via env var, not fraiseql.tomlecho "JWT_SECRET=your-256-bit-secret" >> .envConvert Prisma Schema to FraiseQL Types (Days 2-3)
Translate each Prisma model to a FraiseQL type using Python decorators. The FraiseQL Rust binary reads your SQL views (v_*) for queries — no Python resolvers needed.
model User { id Int @id @default(autoincrement()) email String @unique name String posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt}
model Post { id Int @id @default(autoincrement()) title String content String published Boolean @default(false) userId Int user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now())}
model Comment { id Int @id @default(autoincrement()) text String postId Int userId Int post Post @relation(fields: [postId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id])}import fraiseqlfrom fraiseql import IDfrom fraiseql.scalars import DateTime
@fraiseql.typeclass User: id: ID email: str # Unique enforced by database name: str posts: list['Post'] created_at: DateTime updated_at: DateTime
@fraiseql.typeclass Post: id: ID title: str content: str published: bool user: User comments: list['Comment'] created_at: DateTime
@fraiseql.typeclass Comment: id: ID text: str post: Post user: Userimport { Type, ID } from 'fraiseql';
@Type()class User { id!: ID; email!: string; name!: string; posts!: Post[]; createdAt!: Date; updatedAt!: Date;}
@Type()class Post { id!: ID; title!: string; content!: string; published!: boolean; userId!: ID; user!: User; comments!: Comment[]; createdAt!: Date;}
@Type()class Comment { id!: ID; text!: string; postId!: ID; userId!: ID; post!: Post; user!: User;}import "github.com/fraiseql/fraiseql-go"
type User struct { ID fraiseql.ID `fraiseql:"id"` Email string `fraiseql:"email"` Name string `fraiseql:"name"` Posts []Post `fraiseql:"posts"` CreatedAt time.Time `fraiseql:"created_at"` UpdatedAt time.Time `fraiseql:"updated_at"`}
type Post struct { ID fraiseql.ID `fraiseql:"id"` Title string `fraiseql:"title"` Content string `fraiseql:"content"` Published bool `fraiseql:"published"` UserID fraiseql.ID `fraiseql:"user_id"` User User `fraiseql:"user"` Comments []Comment `fraiseql:"comments"` CreatedAt time.Time `fraiseql:"created_at"`}
type Comment struct { ID fraiseql.ID `fraiseql:"id"` Text string `fraiseql:"text"` PostID fraiseql.ID `fraiseql:"post_id"` UserID fraiseql.ID `fraiseql:"user_id"` Post Post `fraiseql:"post"` User User `fraiseql:"user"`}Define Queries backed by SQL Views
FraiseQL queries map directly to PostgreSQL views (v_*). Write the SQL view, then declare the query with @fraiseql.query.
const users = await prisma.user.findMany({ where: { posts: { some: {} } }, // Has posts include: { posts: true }, orderBy: { createdAt: 'desc' }, take: 10});@fraiseql.querydef users( limit: int = 10, offset: int = 0, has_posts: bool = False) -> list[User]: """Get users, optionally with posts.""" return fraiseql.config(sql_source="v_users_with_posts")-- The backing SQL viewCREATE VIEW v_users_with_posts ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'email', u.email, 'name', u.name, 'posts', COALESCE( jsonb_agg( jsonb_build_object('id', p.id::text, 'title', p.title, 'content', p.content) ) FILTER (WHERE p.pk_post IS NOT NULL), '[]'::jsonb ) ) AS dataFROM tb_user uLEFT JOIN tb_post p ON p.fk_user = u.pk_userWHERE (SELECT COUNT(*) FROM tb_post fp WHERE fp.fk_user = u.pk_user) > 0GROUP BY u.pk_user, u.id;import { Query } from 'fraiseql';
@Query({ sqlSource: 'v_users_with_posts' })function users(limit: number = 10, offset: number = 0, hasPosts: boolean = false): Promise<User[]> { return Promise.resolve([]);}-- The backing SQL viewCREATE VIEW v_users_with_posts ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'email', u.email, 'name', u.name, 'posts', COALESCE( jsonb_agg( jsonb_build_object('id', p.id::text, 'title', p.title, 'content', p.content) ) FILTER (WHERE p.pk_post IS NOT NULL), '[]'::jsonb ) ) AS dataFROM tb_user uLEFT JOIN tb_post p ON p.fk_user = u.pk_userWHERE (SELECT COUNT(*) FROM tb_post fp WHERE fp.fk_user = u.pk_user) > 0GROUP BY u.pk_user, u.id;import "github.com/fraiseql/fraiseql-go"
func init() { fraiseql.Query("users", fraiseql.QueryConfig{ SQLSource: "v_users_with_posts", }, func(args fraiseql.Args) ([]User, error) { return nil, nil })}-- The backing SQL viewCREATE VIEW v_users_with_posts ASSELECT u.id, jsonb_build_object( 'id', u.id::text, 'identifier', u.identifier, 'email', u.email, 'name', u.name, 'posts', COALESCE( jsonb_agg( jsonb_build_object('id', p.id::text, 'title', p.title, 'content', p.content) ) FILTER (WHERE p.pk_post IS NOT NULL), '[]'::jsonb ) ) AS dataFROM tb_user uLEFT JOIN tb_post p ON p.fk_user = u.pk_userWHERE (SELECT COUNT(*) FROM tb_post fp WHERE fp.fk_user = u.pk_user) > 0GROUP BY u.pk_user, u.id;Define Mutations backed by PostgreSQL Functions
FraiseQL mutations map to PostgreSQL functions (fn_*). The Rust binary calls these functions — no ORM layer involved.
const user = await prisma.user.create({ data: { email: "alice@example.com", name: "Alice", }});@fraiseql.inputclass CreateUserInput: email: str name: str
@fraiseql.mutation(sql_source="fn_create_user", operation="CREATE")def create_user(input: CreateUserInput) -> User: """Create user via PostgreSQL function.""" pass-- The backing PostgreSQL functionCREATE FUNCTION fn_create_user(p_email TEXT, p_name TEXT)RETURNS mutation_response AS $$DECLARE v_id UUID := gen_random_uuid(); v_result mutation_response;BEGIN INSERT INTO tb_user (id, identifier, email, name) VALUES (v_id, p_email, p_email, p_name);
v_result.status := 'success'; v_result.entity_id := v_id; v_result.entity_type := 'User'; v_result.entity := (SELECT data FROM v_user WHERE id = v_id); RETURN v_result;EXCEPTION WHEN unique_violation THEN v_result.status := 'conflict:duplicate_email'; v_result.message := 'A user with that email already exists.'; RETURN v_result;END;$$ LANGUAGE plpgsql;import { Input, Mutation } from 'fraiseql';
@Input()class CreateUserInput { email!: string; name!: string;}
@Mutation({ sqlSource: 'fn_create_user', operation: 'CREATE' })function createUser(input: CreateUserInput): Promise<User> { return Promise.resolve({} as User);}-- The backing PostgreSQL functionCREATE FUNCTION fn_create_user(p_email TEXT, p_name TEXT)RETURNS mutation_response AS $$DECLARE v_id UUID := gen_random_uuid(); v_result mutation_response;BEGIN INSERT INTO tb_user (id, identifier, email, name) VALUES (v_id, p_email, p_email, p_name);
v_result.status := 'success'; v_result.entity_id := v_id; v_result.entity_type := 'User'; v_result.entity := (SELECT data FROM v_user WHERE id = v_id); RETURN v_result;EXCEPTION WHEN unique_violation THEN v_result.status := 'conflict:duplicate_email'; v_result.message := 'A user with that email already exists.'; RETURN v_result;END;$$ LANGUAGE plpgsql;import "github.com/fraiseql/fraiseql-go"
type CreateUserInput struct { Email string `fraiseql:"email"` Name string `fraiseql:"name"`}
func init() { fraiseql.Mutation("createUser", fraiseql.MutationConfig{ SQLSource: "fn_create_user", Operation: "CREATE", }, func(input CreateUserInput) (User, error) { return User{}, nil })}-- The backing PostgreSQL functionCREATE FUNCTION fn_create_user(p_email TEXT, p_name TEXT)RETURNS mutation_response AS $$DECLARE v_id UUID := gen_random_uuid(); v_result mutation_response;BEGIN INSERT INTO tb_user (id, identifier, email, name) VALUES (v_id, p_email, p_email, p_name);
v_result.status := 'success'; v_result.entity_id := v_id; v_result.entity_type := 'User'; v_result.entity := (SELECT data FROM v_user WHERE id = v_id); RETURN v_result;EXCEPTION WHEN unique_violation THEN v_result.status := 'conflict:duplicate_email'; v_result.message := 'A user with that email already exists.'; RETURN v_result;END;$$ LANGUAGE plpgsql;Update Client Code (Days 4-5)
import { prisma } from "@/lib/prisma";
export async function getServerSideProps() { const users = await prisma.user.findMany({ include: { posts: true }, take: 10 }); return { props: { users } };}
export default function UsersPage({ users }) { return ( <div> {users.map(user => ( <div key={user.id}> <h2>{user.name}</h2> <p>{user.posts.length} posts</p> </div> ))} </div> );}import { Client } from "@fraiseql/client";
const client = new Client({ url: "https://api.example.com/graphql", apiKey: process.env.FRAISEQL_API_KEY});
export async function getServerSideProps() { const response = await client.query(` query GetUsers { users(limit: 10) { id name posts { id title } } } `);
return { props: { users: response.data.users } };}
export default function UsersPage({ users }) { return ( <div> {users.map(user => ( <div key={user.id}> <h2>{user.name}</h2> <p>{user.posts.length} posts</p> </div> ))} </div> );}Deploy FraiseQL Backend (Day 7)
# Build FraiseQL binaryfraiseql compile
# Deploy (example: Docker)docker build -t fraiseql-api .docker push your-registry/fraiseql-api
# Deploy to Kubernetes/Cloudkubectl apply -f fraiseql-deployment.yaml# .env.local — no database URL needed on the frontendFRAISEQL_URL=https://api.example.com/graphqlFRAISEQL_API_KEY=sk_...Frontend -> Prisma Client -> Database(Direct database access)Frontend -> HTTP/GraphQL -> FraiseQL Rust Binary -> PostgreSQL Views/Functions(Backend API layer, no ORM)Benefits of FraiseQL approach:
const users = await prisma.user.findMany({ skip: 10, take: 10});query { users(offset: 10, limit: 10) { id name }}const posts = await prisma.post.findMany({ where: { AND: [ { published: true }, { author: { email: { contains: "@example.com" } } } ] }});query { posts( published_eq: true author_email_contains: "@example.com" ) { id title }}In FraiseQL, aggregate queries are expressed as dedicated SQL views with pre-computed aggregations. Write a v_post_stats view that computes the values you need, then expose it as a normal query.
const result = await prisma.post.aggregate({ where: { published: true }, _count: true, _avg: { rating: true }});-- Aggregation as a dedicated stats viewCREATE VIEW v_post_stats ASSELECT gen_random_uuid() AS id, jsonb_build_object( 'published_count', COUNT(*) FILTER (WHERE is_published), 'total_count', COUNT(*), 'avg_rating', AVG(rating) FILTER (WHERE is_published) ) AS dataFROM tb_post;@fraiseql.typeclass PostStats: id: ID published_count: int total_count: int avg_rating: float | None
@fraiseql.querydef post_stats() -> PostStats | None: """Get aggregate post statistics.""" return fraiseql.config(sql_source="v_post_stats")// N+1 problemconst users = await prisma.user.findMany();for (const user of users) { const posts = await prisma.post.findMany({ where: { userId: user.id } }); // N queries!}query { users { name posts { # Backed by a SQL JOIN in the view, not a separate query title } }}# 1 pre-compiled view query (posts JOIN users — no separate queries)FraiseQL Python decorators are compile-time schema definitions only — there is no Python middleware layer in FraiseQL. Traffic routing during a gradual rollout is handled at the reverse proxy level, not in application code.
Route traffic between your existing Prisma API and the new FraiseQL binary using your API gateway or reverse proxy:
# nginx — route to FraiseQL for users in the rollout group,# fall back to the existing Prisma/Node.js backend otherwise.map $http_x_rollout_group $backend { "fraiseql" "http://fraiseql-api:8080"; default "http://prisma-api:4000";}
server { location /graphql { proxy_pass $backend; }}The same principle applies with AWS ALB weighted target groups, Cloudflare Workers, or any feature-flag proxy. Because both backends read the same PostgreSQL database, you can route individual requests to either one without data inconsistency.
Once you have validated FraiseQL is producing correct results, shift 100% of traffic to it and decommission the Prisma/Node.js backend.
You do not have to choose one or the other. A common pattern during migration — and even long-term — is to use Prisma for your admin panel or internal tooling while FraiseQL serves the public-facing GraphQL API.
Why this works well:
┌─────────────────────────────────┐ │ PostgreSQL Database │ └──────────────┬──────────────────┘ │ ┌───────────────────┴───────────────────┐ │ │ ┌───────────▼──────────┐ ┌──────────────▼────────┐ │ Prisma Client │ │ FraiseQL Rust Binary │ │ (Admin / Internal) │ │ (Public GraphQL API) │ └───────────────────────┘ └────────────────────────┘Setup:
# prisma/schema.prisma — unchanged, used by admin tooling# fraiseql.toml — points to the same DATABASE_URL// admin/seed.ts — Prisma for admin operationsimport { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();await prisma.user.createMany({ data: seedData });# Public API served by FraiseQLquery { users(limit: 20, orderBy: "created_at DESC") { id name posts { title } }}v_*) for all queriesfn_*) for all mutationsv_* viewsfn_* functionsFraiseQL vs Prisma
Side-by-side feature and architecture comparison.
Apollo Server Migration
Migrate from another popular GraphQL option.
Hasura Migration
Coming from Hasura’s database-first GraphQL.
Getting Started
Learn FraiseQL fundamentals from scratch.
Schema Concepts
Understand SQL views, types, and decorators.