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:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
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:
| Approach | How it works | Drift risk |
|---|---|---|
| GraphQL-first | Design the GraphQL schema, then implement resolvers and migrations separately. | High: implementation can lag the schema |
| Code-first | Generate GraphQL from application code (e.g. Ent or gqlgen in Go). | Medium: schema reflects resolvers, not necessarily the DB |
| Database-driven | Derive 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 "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. Sincemigrationsdoes 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).
- 20260604065020_create_tables.sql
- 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
);
h1:lydlSEofbo9VemKbKs7e9q7ZOjp63lzHzoBJcneIGK0=
20260604065020_create_tables.sql h1:dwT7VtbbHKn6AhymYAm9bD2DZwkkOfk3u38wY2waefg=
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"loadsschema.pg.hclas the desired database state. - Annotation types:
annotation "table"andannotation "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 writespecs/schema.graphqland per-table files underspecs/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.
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
}
}
}
- 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.tmplis applied to the entire schema andtype.graphql.tmplis applied to each table. - The results are written to
specs/schema.graphqlandspecs/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:
- users
- articles
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]
}
}
table "articles" {
schema = schema.public
annotation {
type_name = "Article"
description = "A published article"
query {
name = "articles"
return_type = "[Article!]!"
description = "Fetch all articles"
}
query {
name = "article"
return_type = "Article"
description = "Fetch an article by ID"
arg {
name = "id"
type = "ID!"
}
}
mutation {
name = "createArticle"
return_type = "Article!"
description = "Create a new article"
arg {
name = "title"
type = "String!"
}
arg {
name = "body"
type = "String!"
}
arg {
name = "authorId"
type = "ID!"
}
}
}
column "id" {
null = false
type = serial
annotation {
graphql_type = "ID!"
description = "Unique identifier"
}
}
column "title" {
null = false
type = text
annotation {
graphql_type = "String!"
description = "Article title"
}
}
column "body" {
null = false
type = text
annotation {
graphql_type = "String!"
description = "Article body content"
}
}
column "author_id" {
null = false
type = int
annotation {
graphql_type = "ID!"
description = "ID of the author"
}
}
primary_key {
columns = [column.id]
}
foreign_key "fk_author" {
columns = [column.author_id]
ref_columns = [table.users.column.id]
on_delete = CASCADE
}
}
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.
| Annotation | Purpose |
|---|---|
table.type_name | GraphQL type name for the table (e.g. User) |
table.query | Query field name, return type, description, and optional arguments |
table.mutation | Mutation field name, return type, description, and arguments |
column.graphql_type | GraphQL type for the column (e.g. String!, ID!) |
column.description | Field documentation in the generated SDL |
column.deprecated | Optional @deprecated reason for the field |
Add Go templates
Copy the Go templates into a templates/ directory:
- schema.graphql.tmpl
- type.graphql.tmpl
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.
{{- 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 }}
}
The per-table template runs once for each table (via the exporter's on "table" block) and writes separate
files under specs/types/ for each table. Each file is a single GraphQL object type with table-level
description from annotations and one field per column with its documented GraphQL type.
{{- with $ann := attr .Object "annotation" -}}
"""
{{ $ann.Attr "description" }}
"""
type {{ $ann.Attr "type_name" }} {
{{- range $c := $.Object.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 }}
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:
- schema.graphql
- types/articles.graphql
- types/users.graphql
"""
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!
}
"""
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!
}
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:
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
- schema.graphql
"""
A registered user
"""
type User {
"""
Unique identifier
"""
id: ID!
"""
Email address
"""
email: String!
"""
Creation timestamp
"""
created_at: String!
"""
Phone number
"""
phone: String
}
# ... existing schema
"""
A registered user
"""
type User {
"""
Unique identifier
"""
id: ID!
"""
Email address
"""
email: String!
"""
Creation timestamp
"""
created_at: String!
"""
Phone number
"""
phone: String
}
# ...