Modern backend development often requires balancing flexibility for clients with performance and safety in the data layer. Two powerful tools in the Go ecosystem — SQLC and GraphQL — can be combined to achieve exactly that: type‑safe database queries with flexible client‑side data fetching.

🔹 What is SQLC?

SQLC is a code generator that takes raw SQL queries and produces type‑safe Go functions. Instead of writing boilerplate database/sql code, you define queries in .sql files, and SQLC generates Go structs and methods that map directly to your schema.

Benefits:

  • Compile‑time safety for SQL queries.
  • No ORM overhead — queries are written in plain SQL.
  • Strongly typed Go models.

🔹 What is GraphQL?

GraphQL is a query language and runtime for APIs. Unlike REST, it allows clients to:

  • Request exactly the fields they need.
  • Combine multiple resources in a single query.
  • Handle evolving schemas gracefully.

In Go, gqlgen is the most popular library for building GraphQL servers. It generates type‑safe resolvers from your schema.

🔹 Why Combine SQLC and GraphQL?

  • SQLC ensures correctness and performance at the database layer.
  • GraphQL 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.

Example: Product Catalogue

1. Define SQLC Query

internal/storage/sql/queries/products.sql:

sql
-- name: ListProductsWithFilters :many
SELECT
    id,
    name,
    description,
    price,
    is_active
FROM products
WHERE (sqlc.narg('id')::int IS NULL OR id = sqlc.narg('id'))
  AND (
      sqlc.narg('name')::text IS NULL
      OR name ILIKE '%' || sqlc.narg('name') || '%'
  )
  AND (
      sqlc.narg('min_price')::bigint IS NULL
      OR price >= sqlc.narg('min_price')
  )
  AND (
      sqlc.narg('max_price')::bigint IS NULL
      OR price <= sqlc.narg('max_price')
  )
  AND (
      sqlc.narg('is_active')::bool IS NULL
      OR is_active = sqlc.narg('is_active')
  );

SQLC generates:

go
type ListProductsWithFiltersParams struct {
    ID sql.NullInt32
    Name sql.NullString
    MinPrice sql.NullInt64
    MaxPrice sql.NullInt64
    IsActive sql.NullBool
}
func (q *Queries) ListProductsWithFilters(ctx context.Context, arg ListProductsWithFiltersParams)
([]Product, error)

2. Define GraphQL Schema

schema.graphqls:

graphql
scalar Int64
input ProductFilter {
  id: Int  name: String  minPrice: Int64  maxPrice: Int64  isActive: Boolean
}

type Product {
  id: Int!  name: String!  description: String  price: Int64!  isActive: Boolean!
}

type Query {
  products(filter: ProductFilter): [Product!]!
}

3. Implement Resolver Using SQLC

go
func (r *queryResolver) Products(ctx context.Context, filter *model.ProductFilter)
([]*model.Product, error) {
    params := sqlc.ListProductsWithFiltersParams{
        ID: toNullInt32(filter.ID), Name: toNullString(filter.Name), MinPrice:
        toNullInt64(filter.MinPrice), MaxPrice: toNullInt64(filter.MaxPrice), IsActive:
        toNullBool(filter.IsActive),
    }
    products, err := r.Queries.ListProductsWithFilters(ctx, params)
    if err != nil {
        return nil, fmt.Errorf("failed to list products: %w", err)
    }
    var result []*model.Product
    for _, p := range products {
        result = append(result, &model.Product{
            ID: int(p.ID), Name: p.Name, Description: &p.Description, Price: p.Price, IsActive:
            p.IsActive,
        })
    }
    return result, nil
}

4. Query from Client

graphql
query {
  products(filter: { name: "phone", minPrice: 500, isActive: true }) {
    id
    name
    price
  }
}

This translates into an SQL query with WHERE name ILIKE '%phone%' AND price >= 500 AND is_active = true.

🔹 Advantages of This Approach

  • Type Safety: Both SQLC and gqlgen generate Go code from definitions, catching errors at compile time.
  • Performance: SQLC runs raw SQL, avoiding ORM overhead.
  • Flexibility: GraphQL lets clients shape responses without extra endpoints.
  • Maintainability: Schema changes in DB → SQLC → GraphQL are propagated consistently.

Tackling Joins with SQLC in Go Projects

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 joins across multiple tables — 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.

🔹 The Problem

When you join two tables, say users and products, the result set contains fields from both. SQLC generates a struct for each query, but:

  • The struct is flat — it doesn’t automatically nest User and Product objects.
  • You can’t directly reuse the generated User or Product models unless the query matches them exactly.
  • Filtering across both tables requires careful handling of nullable parameters.

This makes it tricky to expose joined data in higher‑level APIs like GraphQL, where clients expect nested objects.

🔹 Example Scenario

You want to fetch products along with their owners:

sql
SELECT
    u.id AS user_id,
    u.name AS user_name,
    p.id AS product_id,
    p.name AS product_name,
    p.price
FROM users AS u
JOIN products AS p ON p.user_id = u.id;

SQLC generates:

go
type ListUsersWithProductsRow struct {
    UserID int32
    UserName string
    ProductID int32
    ProductName string
    Price int64
}

This is correct but flat. You don’t get a nested User inside Product.

🔹 Solutions

1. Accept Flat Rows, Map Manually

The simplest approach is to accept SQLC’s flat struct and map it into nested GraphQL models:

go
for _, row := range rows {    result = append(result, &model.UserProduct{        User: &model.User{
ID:   int(row.UserID),            Name: row.UserName,        },        Product: &model.Product{
ID:    int(row.ProductID),            Name:  row.ProductName,            Price: row.Price,        },
})}

This way, GraphQL clients see nested objects even though SQLC gave you a flat struct.

2. Use sqlc.narg() for Flexible Filters

To support filtering on both tables, write queries with nullable arguments:

sql
-- name: ListUsersWithProducts :many
SELECT
    u.id AS user_id,
    u.name AS user_name,
    p.id AS product_id,
    p.name AS product_name,
    p.price
FROM users AS u
JOIN products AS p ON p.user_id = u.id
WHERE (
        sqlc.narg('user_name')::text IS NULL
        OR u.name ILIKE '%' || sqlc.narg('user_name') || '%'
    )
  AND (
        sqlc.narg('product_name')::text IS NULL
        OR p.name ILIKE '%' || sqlc.narg('product_name') || '%'
    )
  AND (
        sqlc.narg('min_price')::bigint IS NULL
        OR p.price >= sqlc.narg('min_price')
    );

SQLC generates a ListUsersWithProductsParams struct with nullable fields, making it easy to pass filters from GraphQL.

3. Define GraphQL Input Types

Expose filters in GraphQL:

graphql
input UserProductFilter {
  userName: String  productName: String  minPrice: Int64
}

type UserProduct {
  user: User!  product: Product!
}

type Query {
  userProducts(filter: UserProductFilter): [UserProduct!]!
}

4. Resolver Maps GraphQL → SQLC

go
func (r *queryResolver) UserProducts(ctx context.Context, filter *model.UserProductFilter)
([]*model.UserProduct, error) {
    params := sqlc.ListUsersWithProductsParams{
        UserName:
        toNullString(filter.UserName), ProductName: toNullString(filter.ProductName), MinPrice:
        toNullInt64(filter.MinPrice),
    }
    rows, err := r.Queries.ListUsersWithProducts(ctx, params)
    if err != nil {
        return nil, err
    }
    var result []*model.UserProduct
    for _, row := range rows {
        result = append(result, &model.UserProduct{
            User: &model.User{
                ID: int(row.UserID), Name: row.UserName
            }, Product: &model.Product{
                ID: int(row.ProductID), Name: row.ProductName, Price: row.Price
            },
        })
    }
    return result, nil
}

Joining Data in GraphQL with SQLC: The Two‑Query Merge Approach

When building APIs in Go with GraphQL and SQLC, one common challenge is handling relationships between tables. For example, you might have a users table and a products table, and you want to expose a GraphQL query that returns products along with their associated users.

The straightforward way is to write a single SQL join query and let SQLC generate a flat struct. But sometimes you want to reuse existing queries or keep SQL simpler. In that case, you can run two separate queries — one for users and one for products — and then merge them in Go before returning the GraphQL response.

🔹 Why Use the Two‑Query Merge Approach?

  • Reusability: You can reuse existing SQLC queries (ListUsersWithFilters, ListProductsWithFilters) without writing new join queries.
  • Modularity: Each query is focused on one table, making them easier to maintain.
  • Flexibility: You can apply independent filters on users and products, then merge results in Go.

🔹 GraphQL Schema

We define a query that accepts filters for both users and products:

graphql
input UserFilter {
  id: Int  name: String  email: String
}

input ProductFilter {
  id: Int  name: String  minPrice: Int64  maxPrice: Int64  isActive: Boolean
}

type UserProduct {
  user: User!  product: Product!
}

type Query {
  userProducts(userFilter: UserFilter, productFilter: ProductFilter): [UserProduct!]!
}

This schema allows clients to filter on both user fields and product fields.

🔹 Resolver Implementation (Go)

Here’s how the resolver looks when you run two SQLC queries and merge results in Go:

go
func (r *queryResolver) UserProducts(
ctx context.Context,
userFilter *model.UserFilter,
productFilter *model.ProductFilter,) ([]*model.UserProduct, error) {
    // Step 1: Run users query with filters
    userParams := sqlc.ListUsersWithFiltersParams{
        ID:
        toNullInt32(userFilter.ID), Name: toNullString(userFilter.Name), Email:
        toNullString(userFilter.Email),
    }
    users, err := r.Queries.ListUsersWithFilters(ctx, userParams)
    if err != nil {
        return nil, fmt.Errorf("failed to list users: %w", err)
    }
    // Step 2: Run products query with filters
    productParams := sqlc.ListProductsWithFiltersParams{
        ID: toNullInt32(productFilter.ID), Name: toNullString(productFilter.Name), MinPrice:
        toNullInt64(productFilter.MinPrice), MaxPrice: toNullInt64(productFilter.MaxPrice),
        IsActive: toNullBool(productFilter.IsActive),
    }
    products, err := r.Queries.ListProductsWithFilters(ctx, productParams)
    if err != nil {
        return nil, fmt.Errorf("failed to list products: %w", err)
    }
    // Step 3: Merge results in Go
    var result []*model.UserProduct
    for _, u := range users {
        for _, p := range products {
            if p.UserID == u.ID {
                // join condition result = append(result, &model.UserProduct{
                    User: &model.User{
                        ID:
                        int(u.ID), Name: u.Name, Email: u.Email,
                    }, Product: &model.Product{
                        ID: int(p.ID), Name: p.Name, Description: &p.Description, Price: p.Price,
                        IsActive:
                        p.IsActive,
                    },
                })
            }
        }
    }
    return result, nil
}

🔹 Example Query

Clients can now query with conditions on both sides:

graphql
query {
  userProducts(
    userFilter: { name: "Alice" }
    productFilter: { minPrice: 500, isActive: true }
  ) {
    user {
      id
      name
      email
    }
    product {
      id
      name
      price
    }
  }
}

This query fetches all products priced above 500 that belong to users named “Alice.”

🔹 Pros and Cons

Pros:

  • Easy to implement with existing SQLC queries.
  • Clear separation of concerns (users vs products).
  • Flexible filtering on both entities.

Cons:

  • 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.

🔹 Conclusion

The two‑query merge approach 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.

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).