{
  "slug": "Combining-SQLC-and-GraphQL-in-Go-for-Type-Safe-APIs-0b77c1ae0249",
  "title": "Combining SQLC and GraphQL in Go for Type‑Safe APIs",
  "subtitle": "Modern backend development often requires balancing flexibility for clients with performance and safety in the data layer. Two powerful…",
  "excerpt": "Modern backend development often requires balancing flexibility for clients with performance and safety in the data layer. Two powerful…",
  "date": "2025-12-29",
  "tags": [
    "Go",
    "GraphQL",
    "Performance"
  ],
  "readingTime": "7 min",
  "url": "https://medium.com/@mobinshaterian/combining-sqlc-and-graphql-in-go-for-type-safe-apis-0b77c1ae0249",
  "hero": "https://cdn-images-1.medium.com/max/800/1*0M_opUQa7JpKM9dj86-FHQ.png",
  "content": [
    {
      "type": "paragraph",
      "html": "Modern backend development often requires balancing <strong>flexibility</strong> for clients with <strong>performance and safety</strong> in the data layer. Two powerful tools in the Go ecosystem — <strong>SQLC</strong> and <strong>GraphQL</strong> — can be combined to achieve exactly that: type‑safe database queries with flexible client‑side data fetching."
    },
    {
      "type": "image",
      "src": "https://cdn-images-1.medium.com/max/800/1*0M_opUQa7JpKM9dj86-FHQ.png",
      "alt": "Combining SQLC and GraphQL in Go for Type‑Safe APIs",
      "caption": "",
      "width": 1024,
      "height": 1024
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 What is SQLC?"
    },
    {
      "type": "paragraph",
      "html": "SQLC is a code generator that takes raw SQL queries and produces type‑safe Go functions. Instead of writing boilerplate <code>database/sql</code> code, you define queries in&nbsp;<code>.sql</code> files, and SQLC generates Go structs and methods that map directly to your schema."
    },
    {
      "type": "paragraph",
      "html": "<strong>Benefits:</strong>"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "Compile‑time safety for SQL queries.",
        "No ORM overhead — queries are written in plain SQL.",
        "Strongly typed Go models."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 What is GraphQL?"
    },
    {
      "type": "paragraph",
      "html": "GraphQL is a query language and runtime for APIs. Unlike REST, it allows clients to:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "Request exactly the fields they need.",
        "Combine multiple resources in a single query.",
        "Handle evolving schemas gracefully."
      ]
    },
    {
      "type": "paragraph",
      "html": "In Go, gqlgen is the most popular library for building GraphQL servers. It generates type‑safe resolvers from your schema."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Why Combine SQLC and GraphQL?"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<strong>SQLC</strong> ensures correctness and performance at the database layer.",
        "<strong>GraphQL</strong> provides flexibility and efficiency at the API layer.",
        "Together, they eliminate runtime surprises: queries are validated at compile time, and GraphQL resolvers map directly to SQLC‑generated functions."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Example: Product Catalogue"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "1. Define SQLC Query"
    },
    {
      "type": "paragraph",
      "html": "<code>internal/storage/sql/queries/products.sql</code>:"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "-- name: ListProductsWithFilters :many\nSELECT\n    id,\n    name,\n    description,\n    price,\n    is_active\nFROM products\nWHERE (sqlc.narg('id')::int IS NULL OR id = sqlc.narg('id'))\n  AND (\n      sqlc.narg('name')::text IS NULL\n      OR name ILIKE '%' || sqlc.narg('name') || '%'\n  )\n  AND (\n      sqlc.narg('min_price')::bigint IS NULL\n      OR price >= sqlc.narg('min_price')\n  )\n  AND (\n      sqlc.narg('max_price')::bigint IS NULL\n      OR price <= sqlc.narg('max_price')\n  )\n  AND (\n      sqlc.narg('is_active')::bool IS NULL\n      OR is_active = sqlc.narg('is_active')\n  );"
    },
    {
      "type": "paragraph",
      "html": "SQLC generates:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "type ListProductsWithFiltersParams struct {\n    ID sql.NullInt32\n    Name sql.NullString\n    MinPrice sql.NullInt64\n    MaxPrice sql.NullInt64\n    IsActive sql.NullBool\n}\nfunc (q *Queries) ListProductsWithFilters(ctx context.Context, arg ListProductsWithFiltersParams)\n([]Product, error)"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "2. Define GraphQL Schema"
    },
    {
      "type": "paragraph",
      "html": "<code>schema.graphqls</code>:"
    },
    {
      "type": "code",
      "lang": "graphql",
      "code": "scalar Int64\ninput ProductFilter {\n  id: Int  name: String  minPrice: Int64  maxPrice: Int64  isActive: Boolean\n}\n\ntype Product {\n  id: Int!  name: String!  description: String  price: Int64!  isActive: Boolean!\n}\n\ntype Query {\n  products(filter: ProductFilter): [Product!]!\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "3. Implement Resolver Using SQLC"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func (r *queryResolver) Products(ctx context.Context, filter *model.ProductFilter)\n([]*model.Product, error) {\n    params := sqlc.ListProductsWithFiltersParams{\n        ID: toNullInt32(filter.ID), Name: toNullString(filter.Name), MinPrice:\n        toNullInt64(filter.MinPrice), MaxPrice: toNullInt64(filter.MaxPrice), IsActive:\n        toNullBool(filter.IsActive),\n    }\n    products, err := r.Queries.ListProductsWithFilters(ctx, params)\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to list products: %w\", err)\n    }\n    var result []*model.Product\n    for _, p := range products {\n        result = append(result, &model.Product{\n            ID: int(p.ID), Name: p.Name, Description: &p.Description, Price: p.Price, IsActive:\n            p.IsActive,\n        })\n    }\n    return result, nil\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "4. Query from Client"
    },
    {
      "type": "code",
      "lang": "graphql",
      "code": "query {\n  products(filter: { name: \"phone\", minPrice: 500, isActive: true }) {\n    id\n    name\n    price\n  }\n}"
    },
    {
      "type": "paragraph",
      "html": "This translates into an SQL query with <code>WHERE name ILIKE '%phone%' AND price &gt;= 500 AND is_active = true</code>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Advantages of This Approach"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<strong>Type Safety:</strong> Both SQLC and gqlgen generate Go code from definitions, catching errors at compile time.",
        "<strong>Performance:</strong> SQLC runs raw SQL, avoiding ORM overhead.",
        "<strong>Flexibility:</strong> GraphQL lets clients shape responses without extra endpoints.",
        "<strong>Maintainability:</strong> Schema changes in DB → SQLC → GraphQL are propagated consistently."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Tackling Joins with SQLC in Go Projects"
    },
    {
      "type": "paragraph",
      "html": "SQLC is a fantastic tool for generating type‑safe Go code from raw SQL queries. It eliminates boilerplate and ensures compile‑time safety. But one common challenge developers face is handling <strong>joins across multiple tables</strong> — especially when the joined data doesn’t map neatly into a single Go struct. This is often referred to as the “joins problem” in SQLC."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 The Problem"
    },
    {
      "type": "paragraph",
      "html": "When you join two tables, say <code>users</code> and <code>products</code>, the result set contains fields from both. SQLC generates a struct for each query, but:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "The struct is <strong>flat</strong> — it doesn’t automatically nest <code>User</code> and <code>Product</code> objects.",
        "You can’t directly reuse the generated <code>User</code> or <code>Product</code> models unless the query matches them exactly.",
        "Filtering across both tables requires careful handling of nullable parameters."
      ]
    },
    {
      "type": "paragraph",
      "html": "This makes it tricky to expose joined data in higher‑level APIs like GraphQL, where clients expect nested objects."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Example Scenario"
    },
    {
      "type": "paragraph",
      "html": "You want to fetch products along with their owners:"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "SELECT\n    u.id AS user_id,\n    u.name AS user_name,\n    p.id AS product_id,\n    p.name AS product_name,\n    p.price\nFROM users AS u\nJOIN products AS p ON p.user_id = u.id;"
    },
    {
      "type": "paragraph",
      "html": "SQLC generates:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "type ListUsersWithProductsRow struct {\n    UserID int32\n    UserName string\n    ProductID int32\n    ProductName string\n    Price int64\n}"
    },
    {
      "type": "paragraph",
      "html": "This is correct but <strong>flat</strong>. You don’t get a nested <code>User</code> inside <code>Product</code>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Solutions"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "1. Accept Flat Rows, Map Manually"
    },
    {
      "type": "paragraph",
      "html": "The simplest approach is to accept SQLC’s flat struct and map it into nested GraphQL models:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "for _, row := range rows {    result = append(result, &model.UserProduct{        User: &model.User{\nID:   int(row.UserID),            Name: row.UserName,        },        Product: &model.Product{\nID:    int(row.ProductID),            Name:  row.ProductName,            Price: row.Price,        },\n})}"
    },
    {
      "type": "paragraph",
      "html": "This way, GraphQL clients see nested objects even though SQLC gave you a flat struct."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "2. Use sqlc.narg() for Flexible Filters"
    },
    {
      "type": "paragraph",
      "html": "To support filtering on both tables, write queries with nullable arguments:"
    },
    {
      "type": "code",
      "lang": "sql",
      "code": "-- name: ListUsersWithProducts :many\nSELECT\n    u.id AS user_id,\n    u.name AS user_name,\n    p.id AS product_id,\n    p.name AS product_name,\n    p.price\nFROM users AS u\nJOIN products AS p ON p.user_id = u.id\nWHERE (\n        sqlc.narg('user_name')::text IS NULL\n        OR u.name ILIKE '%' || sqlc.narg('user_name') || '%'\n    )\n  AND (\n        sqlc.narg('product_name')::text IS NULL\n        OR p.name ILIKE '%' || sqlc.narg('product_name') || '%'\n    )\n  AND (\n        sqlc.narg('min_price')::bigint IS NULL\n        OR p.price >= sqlc.narg('min_price')\n    );"
    },
    {
      "type": "paragraph",
      "html": "SQLC generates a <code>ListUsersWithProductsParams</code> struct with nullable fields, making it easy to pass filters from GraphQL."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "3. Define GraphQL Input Types"
    },
    {
      "type": "paragraph",
      "html": "Expose filters in GraphQL:"
    },
    {
      "type": "code",
      "lang": "graphql",
      "code": "input UserProductFilter {\n  userName: String  productName: String  minPrice: Int64\n}\n\ntype UserProduct {\n  user: User!  product: Product!\n}\n\ntype Query {\n  userProducts(filter: UserProductFilter): [UserProduct!]!\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "4. Resolver Maps GraphQL → SQLC"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func (r *queryResolver) UserProducts(ctx context.Context, filter *model.UserProductFilter)\n([]*model.UserProduct, error) {\n    params := sqlc.ListUsersWithProductsParams{\n        UserName:\n        toNullString(filter.UserName), ProductName: toNullString(filter.ProductName), MinPrice:\n        toNullInt64(filter.MinPrice),\n    }\n    rows, err := r.Queries.ListUsersWithProducts(ctx, params)\n    if err != nil {\n        return nil, err\n    }\n    var result []*model.UserProduct\n    for _, row := range rows {\n        result = append(result, &model.UserProduct{\n            User: &model.User{\n                ID: int(row.UserID), Name: row.UserName\n            }, Product: &model.Product{\n                ID: int(row.ProductID), Name: row.ProductName, Price: row.Price\n            },\n        })\n    }\n    return result, nil\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "Joining Data in GraphQL with SQLC: The Two‑Query Merge Approach"
    },
    {
      "type": "paragraph",
      "html": "When building APIs in Go with <strong>GraphQL</strong> and <strong>SQLC</strong>, one common challenge is handling relationships between tables. For example, you might have a <code>users</code> table and a <code>products</code> table, and you want to expose a GraphQL query that returns products along with their associated users."
    },
    {
      "type": "paragraph",
      "html": "The straightforward way is to write a single SQL join query and let SQLC generate a flat struct. But sometimes you want to <strong>reuse existing queries</strong> or keep SQL simpler. In that case, you can run <strong>two separate queries</strong> — one for users and one for products — and then merge them in Go before returning the GraphQL response."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Why Use the Two‑Query Merge Approach?"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "<strong>Reusability:</strong> You can reuse existing SQLC queries (<code>ListUsersWithFilters</code>, <code>ListProductsWithFilters</code>) without writing new join queries.",
        "<strong>Modularity:</strong> Each query is focused on one table, making them easier to maintain.",
        "<strong>Flexibility:</strong> You can apply independent filters on users and products, then merge results in Go."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 GraphQL Schema"
    },
    {
      "type": "paragraph",
      "html": "We define a query that accepts filters for both users and products:"
    },
    {
      "type": "code",
      "lang": "graphql",
      "code": "input UserFilter {\n  id: Int  name: String  email: String\n}\n\ninput ProductFilter {\n  id: Int  name: String  minPrice: Int64  maxPrice: Int64  isActive: Boolean\n}\n\ntype UserProduct {\n  user: User!  product: Product!\n}\n\ntype Query {\n  userProducts(userFilter: UserFilter, productFilter: ProductFilter): [UserProduct!]!\n}"
    },
    {
      "type": "paragraph",
      "html": "This schema allows clients to filter on <strong>both user fields and product fields</strong>."
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Resolver Implementation (Go)"
    },
    {
      "type": "paragraph",
      "html": "Here’s how the resolver looks when you run two SQLC queries and merge results in Go:"
    },
    {
      "type": "code",
      "lang": "go",
      "code": "func (r *queryResolver) UserProducts(\nctx context.Context,\nuserFilter *model.UserFilter,\nproductFilter *model.ProductFilter,) ([]*model.UserProduct, error) {\n    // Step 1: Run users query with filters\n    userParams := sqlc.ListUsersWithFiltersParams{\n        ID:\n        toNullInt32(userFilter.ID), Name: toNullString(userFilter.Name), Email:\n        toNullString(userFilter.Email),\n    }\n    users, err := r.Queries.ListUsersWithFilters(ctx, userParams)\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to list users: %w\", err)\n    }\n    // Step 2: Run products query with filters\n    productParams := sqlc.ListProductsWithFiltersParams{\n        ID: toNullInt32(productFilter.ID), Name: toNullString(productFilter.Name), MinPrice:\n        toNullInt64(productFilter.MinPrice), MaxPrice: toNullInt64(productFilter.MaxPrice),\n        IsActive: toNullBool(productFilter.IsActive),\n    }\n    products, err := r.Queries.ListProductsWithFilters(ctx, productParams)\n    if err != nil {\n        return nil, fmt.Errorf(\"failed to list products: %w\", err)\n    }\n    // Step 3: Merge results in Go\n    var result []*model.UserProduct\n    for _, u := range users {\n        for _, p := range products {\n            if p.UserID == u.ID {\n                // join condition result = append(result, &model.UserProduct{\n                    User: &model.User{\n                        ID:\n                        int(u.ID), Name: u.Name, Email: u.Email,\n                    }, Product: &model.Product{\n                        ID: int(p.ID), Name: p.Name, Description: &p.Description, Price: p.Price,\n                        IsActive:\n                        p.IsActive,\n                    },\n                })\n            }\n        }\n    }\n    return result, nil\n}"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Example Query"
    },
    {
      "type": "paragraph",
      "html": "Clients can now query with conditions on both sides:"
    },
    {
      "type": "code",
      "lang": "graphql",
      "code": "query {\n  userProducts(\n    userFilter: { name: \"Alice\" }\n    productFilter: { minPrice: 500, isActive: true }\n  ) {\n    user {\n      id\n      name\n      email\n    }\n    product {\n      id\n      name\n      price\n    }\n  }\n}"
    },
    {
      "type": "paragraph",
      "html": "This query fetches all products priced above 500 that belong to users named “Alice.”"
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Pros and Cons"
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Pros:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "Easy to implement with existing SQLC queries.",
        "Clear separation of concerns (users vs products).",
        "Flexible filtering on both entities."
      ]
    },
    {
      "type": "heading",
      "level": 3,
      "text": "Cons:"
    },
    {
      "type": "list",
      "ordered": false,
      "items": [
        "Less efficient for large datasets (nested loops in Go).",
        "Requires merging logic in Go instead of letting the database handle joins.",
        "More memory usage if queries return large result sets."
      ]
    },
    {
      "type": "heading",
      "level": 2,
      "text": "🔹 Conclusion"
    },
    {
      "type": "paragraph",
      "html": "The <strong>two‑query merge approach</strong> is a practical solution when you want to reuse SQLC queries and keep SQL simple. It gives you flexibility to filter independently on users and products, then merge results in Go before returning them via GraphQL."
    },
    {
      "type": "paragraph",
      "html": "For small to medium datasets, this approach works well. For larger datasets or performance‑critical paths, consider writing a single SQL join query with filters (Approach 1)."
    }
  ]
}
