Skip to main content

Generate GraphQL Schemas from Your Database Schema with Atlas

GraphQL gives clients a strongly typed contract for reading and writing data. In most backends, that contract sits directly on top of a relational database. For example, the User type maps to the users table, and an edge (or a Relay Connection) might map to a foreign key between two tables.

In such cases, every schema change happens twice. A column added to a table needs a corresponding field on its GraphQL type. A dropped column might break the API contract and require changes to the GraphQL schema as well. The two schemas describe the same data, but they live in different files and evolve in different workflows, with nothing keeping them in sync. In this guide, we'll show how Atlas can fill this gap using its schema exporters.

Enter: Atlas

Atlas treats your database schema as code and can generate GraphQL SDL from the same definitions you use to plan and apply migrations. You declare tables, columns, and API metadata together. Atlas validates the metadata, generates SQL migrations, exports a GraphQL schema (types, queries, and mutations), and keeps everything aligned through your normal CI/CD workflow.

To get started, install Atlas:

To download and install the latest release of the Atlas CLI, simply run the following in your terminal:

curl -sSf https://atlasgo.sh | sh

Maintaining Consistency

When schema and GraphQL SDL live in separate files (or separate repos), they can easily diverge. A phone column added to the users table might ship in a migration but never appear in the API schema. A mutation documented in GraphQL might reference a field that was dropped from the database months ago.

There are several common patterns for keeping APIs and databases aligned:

ApproachHow it worksDrift risk
GraphQL-firstDesign the GraphQL schema, then implement resolvers and migrations separately.High: implementation can lag the schema
Code-firstGenerate GraphQL from application code (e.g. Ent or gqlgen in Go).Medium: schema reflects resolvers, not necessarily the DB
Database-drivenDerive GraphQL types from the database. Add query and mutation metadata via annotations.Low: schema reflects actual tables and columns

This guide uses the database-driven approach. Your tables are the source of truth for data shape. Annotations on those tables describe how they surface as GraphQL types, queries, and mutations. Go templates turn the annotated schema into GraphQL SDL.

After every edit to the schema, both the database and the GraphQL output are updated, preventing drift at the source.

Project Setup

This guide will follow the versioned migrations workflow on a PostgreSQL database. The versioned workflow compares the desired schema against the final state of a migrations directory using a dev database, and writes the diff as SQL statements to the migrations directory in a migration file.

To learn more about the different Atlas workflows, read our Declarative vs. Versioned Migrations guide.

Create a basic versioned workflow

We will begin with a basic schema file (schema.pg.hcl) containing just two tables:

schema.pg.hcl
schema "public" {}

table "users" {
schema = schema.public
column "id" {
null = false
type = serial
}
column "email" {
null = false
type = text
}
column "created_at" {
null = false
type = timestamptz
default = sql("now()")
}
primary_key {
columns = [column.id]
}
}

table "articles" {
schema = schema.public
column "id" {
null = false
type = serial
}
column "title" {
null = false
type = text
}
column "body" {
null = false
type = text
}
column "author_id" {
null = false
type = int
}
primary_key {
columns = [column.id]
}
foreign_key "fk_author" {
columns = [column.author_id]
ref_columns = [table.users.column.id]
on_delete = CASCADE
}
}

Then, we create the first migration file by running the following command:

atlas migrate diff create_tables \
--dir "file://migrations" \
--to "file://schema.pg.hcl" \
--dev-url "docker://postgres/16/dev?search_path=public"
  • --dir: The path for the migrations directory. Since migrations does not yet exist, the "current state" defined by the directory is considered empty and a directory is created for the new migration file.
  • --to: The path to the desired schema file.
  • --dev-url: The connection string to a dev database.

For other databases, refer to our documentation for naming the schema file and configuring the URL for the dev database.

This command creates a migrations directory containing the first migration file (<version>_create_tables.sql) and a directory integrity file (atlas.sum).

-- Create "users" table
CREATE TABLE "users" (
"id" serial NOT NULL,
"email" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("id")
);
-- Create "articles" table
CREATE TABLE "articles" (
"id" serial NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"author_id" integer NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "fk_author" FOREIGN KEY ("author_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);

Generate GraphQL Schema Files

To create a migration, all we had to do was define our schema as code and Atlas did the rest. We can use the same process to generate GraphQL schemas.

Atlas provides a schema exporter that allows you to export your Atlas schema to nearly any format you'd like. In our case, we want to generate GraphQL SDL from our table definitions to ensure that both our migrations and GraphQL schemas are derived from a single source of truth: the Atlas schema.

We'll use the "template" exporter for this. It lets us apply Go templates (the same syntax used by Helm, for example) to our schema objects and generate any assets we want using programmatic templates.

The Go templates utilize annotations to populate our GraphQL schema. Annotation types and their attributes are defined in the Atlas configuration (see below), validated by Atlas at load time, and used in your Atlas schema to add customized metadata for individual tables, columns, etc.

Let's see how this all works together.

Configure atlas.hcl

The configuration file ties together the various pieces of our setup:

  • HCL schema source: data "hcl_schema" "app" loads schema.pg.hcl as the desired database state.
  • Annotation types: annotation "table" and annotation "column" blocks define the structure of the annotations that can be attached to tables and columns (including queries and mutations), and carry the custom GraphQL metadata we use later in our templates.
  • Template exporter: exporter "template" "graphql" is a schema exporter that executes the Go templates that write specs/schema.graphql and per-table files under specs/types/.
  • Environment: env "local" encapsulates the flags used in the CLI command to create our first migration so we can connect our schema to the exporter configuration.
atlas.hcl
data "hcl_schema" "app" {
path = "schema.pg.hcl"

annotation "table" {
attr "type_name" {
type = string
}
attr "description" {
type = string
}
block "query" {
repeatable = true
attr "name" {
type = string
}
attr "return_type" {
type = string
}
attr "description" {
type = string
}
block "arg" {
repeatable = true
attr "name" {
type = string
}
attr "type" {
type = string
}
}
}
block "mutation" {
repeatable = true
attr "name" {
type = string
}
attr "return_type" {
type = string
}
attr "description" {
type = string
}
block "arg" {
repeatable = true
attr "name" {
type = string
}
attr "type" {
type = string
}
}
}
}

annotation "column" {
attr "graphql_type" {
type = string
}
attr "description" {
type = string
}
attr "deprecated" {
type = string
}
}
}

exporter "template" "graphql" {
template {
src = "templates/schema.graphql.tmpl"
name = "specs/schema.graphql"
}
template {
src = "templates/type.graphql.tmpl"
on "table" {
name = "specs/types/{{ .Name }}.graphql"
}
}
}

env "local" {
dev = "docker://postgres/16/dev?search_path=public"
schema {
src = data.hcl_schema.app.url
}
migration {
dir = "file://migrations"
}
export {
schema {
inspect = exporter.template.graphql
}
}
}
How it works
  • Atlas loads your schema and validates the table and column annotations against the types declared in atlas.hcl.
  • Atlas then executes the exporter and runs each template configured in atlas.hcl: schema.graphql.tmpl is applied to the entire schema and type.graphql.tmpl is applied to each table.
  • The results are written to specs/schema.graphql and specs/types/<table>.graphql (for example, specs/types/users.graphql) respectively.

Add GraphQL annotations

Annotations attach API metadata to schema objects. They live in the same file as your table definitions, so reviewers see database and API changes together in every pull request.

Extend schema.pg.hcl with annotation blocks:

table "users" {
schema = schema.public
annotation {
type_name = "User"
description = "A registered user"
query {
name = "users"
return_type = "[User!]!"
description = "Fetch all users"
}
query {
name = "user"
return_type = "User"
description = "Fetch a user by ID"
arg {
name = "id"
type = "ID!"
}
}
mutation {
name = "createUser"
return_type = "User!"
description = "Create a new user"
arg {
name = "email"
type = "String!"
}
}
}
column "id" {
null = false
type = serial
annotation {
graphql_type = "ID!"
description = "Unique identifier"
}
}
column "email" {
null = false
type = text
annotation {
graphql_type = "String!"
description = "Email address"
}
}
column "created_at" {
null = false
type = timestamptz
default = sql("now()")
annotation {
graphql_type = "String!"
description = "Creation timestamp"
}
}
primary_key {
columns = [column.id]
}
}

Annotation types and their attributes are declared in atlas.hcl. Atlas validates every attribute at load time, so typos fail immediately instead of producing a broken schema.

AnnotationPurpose
table.type_nameGraphQL type name for the table (e.g. User)
table.queryQuery field name, return type, description, and optional arguments
table.mutationMutation field name, return type, description, and arguments
column.graphql_typeGraphQL type for the column (e.g. String!, ID!)
column.descriptionField documentation in the generated SDL
column.deprecatedOptional @deprecated reason for the field

Add Go templates

Copy the Go templates into a templates/ directory:

The global template produces one specs/schema.graphql file, looping over every table in the schema. For tables with annotations, it emits GraphQL object types from column names and graphql_type annotations, then builds Query and Mutation types from each table's query and mutation blocks.

templates/schema.graphql.tmpl
{{- range $s := .Realm.Schemas -}}
{{- range $i, $t := $s.Tables -}}
{{- with $ann := attr $t "annotation" -}}
{{ if $i }}

{{ end }}"""
{{ $ann.Attr "description" }}
"""
type {{ $ann.Attr "type_name" }} {
{{- range $c := $t.Columns }}
{{- with $cann := attr $c "annotation" }}
"""
{{ $cann.Attr "description" }}
"""
{{ $c.Name }}: {{ $cann.Attr "graphql_type" }}
{{- with $cann.Attr "deprecated" }} @deprecated(reason: "{{ . }}"){{ end }}
{{- end }}
{{- end }}
}
{{- end }}
{{- end }}
{{- end }}

type Query {
{{- range $s := .Realm.Schemas }}
{{- range $t := $s.Tables }}
{{- with $ann := attr $t "annotation" }}
{{- range $ann.Block "query" }}
"""
{{ .Attr "description" }}
"""
{{ .Attr "name" }}
{{- with .Block "arg" }}({{ range $i, $a := . }}{{ if $i }}, {{ end }}{{ $a.Attr "name" }}: {{ $a.Attr "type" }}{{ end }}){{ end }}: {{ .Attr "return_type" }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
}

type Mutation {
{{- range $s := .Realm.Schemas }}
{{- range $t := $s.Tables }}
{{- with $ann := attr $t "annotation" }}
{{- range $ann.Block "mutation" }}
"""
{{ .Attr "description" }}
"""
{{ .Attr "name" }}
{{- with .Block "arg" }}({{ range $i, $a := . }}{{ if $i }}, {{ end }}{{ $a.Attr "name" }}: {{ $a.Attr "type" }}{{ end }}){{ end }}: {{ .Attr "return_type" }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
}

Generate GraphQL schemas

The --export flag runs the configured exporter "template" "graphql" block. The schema files are generated using the schema definition pointed to by --url.

atlas schema inspect --env local --url "env://schema.src" --export

Your project directory should now look like this:

.
├── atlas.hcl # Project configuration
├── schema.pg.hcl # Desired database state + GraphQL annotations
├── specs/
│ ├── schema.graphql # Global GraphQL schema
│ └── types/
│ ├── articles.graphql # Articles type file
│ └── users.graphql # Users type file
└── templates/
├── schema.graphql.tmpl # Global GraphQL template
└── type.graphql.tmpl # Per-table type template

With the following files in your specs/ directory:

"""
A published article
"""
type Article {
"""
Unique identifier
"""
id: ID!
"""
Article title
"""
title: String!
"""
Article body content
"""
body: String!
"""
ID of the author
"""
author_id: ID!
}

"""
A registered user
"""
type User {
"""
Unique identifier
"""
id: ID!
"""
Email address
"""
email: String!
"""
Creation timestamp
"""
created_at: String!
}

type Query {
"""
Fetch all articles
"""
articles: [Article!]!
"""
Fetch an article by ID
"""
article(id: ID!): Article
"""
Fetch all users
"""
users: [User!]!
"""
Fetch a user by ID
"""
user(id: ID!): User
}

type Mutation {
"""
Create a new article
"""
createArticle(title: String!, body: String!, authorId: ID!): Article!
"""
Create a new user
"""
createUser(email: String!): User!
}

Make a Schema Change

Now that we have everything set up, let's keep the database and GraphQL schemas in sync through a schema change using Atlas.

When you add a column, update both the table definition and any relevant annotations in the same commit. For example, add a phone column to users:

schema.pg.hcl
table "users" {
schema = schema.public
annotation {
type_name = "User"
description = "A registered user"
query {
name = "users"
return_type = "[User!]!"
description = "Fetch all users"
}
query {
name = "user"
return_type = "User"
description = "Fetch a user by ID"
arg {
name = "id"
type = "ID!"
}
}
mutation {
name = "createUser"
return_type = "User!"
description = "Create a new user"
arg {
name = "email"
type = "String!"
}
}
}
column "id" {
null = false
type = serial
}
column "email" {
null = false
type = text
}
column "created_at" {
null = false
type = timestamptz
default = sql("now()")
}
column "phone" {
null = true
type = text
annotation {
graphql_type = "String"
description = "Phone number"
}
}
primary_key {
columns = [column.id]
}
}

Since export reads the same HCL schema used for migrations, a new column in schema.pg.hcl appears in both the next migration and the regenerated GraphQL output (specs/).

Generate a migration:

atlas migrate diff --env local add_phone_to_users

Run export whenever schema or annotations change to sync the GraphQL schemas:

atlas schema inspect --env local --url "env://schema.src" --export

The phone field is added to types/users.graphql and schema.graphql:

types/users.graphql
"""
A registered user
"""
type User {
"""
Unique identifier
"""
id: ID!
"""
Email address
"""
email: String!
"""
Creation timestamp
"""
created_at: String!
"""
Phone number
"""
phone: String
}