Exporting Custom Metadata with the Template Exporter
Atlas's exporter "template" block lets you generate arbitrary files from your schema using Go templates.
Combined with annotations, you can embed rich metadata in your schema definitions and
export them into formats like Hasura metadata, GraphQL schemas, or OpenAPI/Swagger specs.
How It Works
The template exporter works in two steps:
- Define annotations on your schema objects (tables, columns) to attach metadata.
- Write Go templates that read those annotations and produce the desired output format.
The exporter is configured in your atlas.hcl configuration file and triggered via atlas schema inspect --export.
Annotations
Annotations let you attach structured metadata to schema objects (tables, columns, etc.). They are defined in two places:
- Configuration file: An
annotation "type"block inside thehcl_schemadata-source inatlas.hcldefines the annotation schema (allowed attributes and nested blocks). - Schema files:
annotation {}blocks inside resource definitions (tables, columns, etc.) supply the actual metadata values.
Examples
- Hasura Metadata
- GraphQL Schema
- OpenAPI / Swagger
Generating Hasura Metadata
This example generates Hasura metadata YAML files from your Atlas schema, including table tracking, relationships, and permissions.
Step 1: Define Annotation Schema
In atlas.hcl, define the annotation types that the HCL schema can use:
data "hcl_schema" "app" {
path = "schema.hcl"
annotation "table" {
block "object_relationship" {
repeatable = true
attr "name" {
type = string
}
attr "using_fk_column" {
type = string
}
}
block "array_relationship" {
repeatable = true
attr "name" {
type = string
}
attr "foreign_column" {
type = string
}
attr "foreign_table" {
type = string
}
}
block "select_permission" {
repeatable = true
attr "role" {
type = string
}
attr "columns" {
type = list(string)
}
attr "allow_aggregations" {
type = bool
}
block "filter" {
repeatable = true
attr "column" {
type = string
}
attr "op" {
type = string
}
attr "value" {
type = string
}
}
}
}
}
Let's break down what we see in this configuration:
annotation "table"declares a new annotation type namedtablethat can be used insidetableresource definitions inschema.hcl.attr "name" { type = string }defines a scalar attribute (string, bool,list(string), etc.) that the annotation accepts.block "select_permission" { repeatable = true }defines a nested block type. Therepeatable = trueflag means multiple blocks of this type can appear (e.g., one per role).- Blocks can be nested (e.g.,
block "filter"insideblock "select_permission"), allowing arbitrarily deep metadata structures.
Step 2: Annotate Schema Objects
schema "public" {}
table "users" {
schema = schema.public
annotation {
array_relationship {
name = "posts"
foreign_column = "author_id"
foreign_table = "posts"
}
select_permission {
role = "user"
columns = ["id", "name", "email"]
allow_aggregations = false
filter {
column = "id"
op = "_eq"
value = "X-Hasura-User-Id"
}
}
select_permission {
role = "admin"
columns = ["id", "name", "email"]
allow_aggregations = true
filter {
column = "id"
op = "_ne"
value = "0"
}
}
}
column "id" {
null = false
type = int
}
column "name" {
null = false
type = text
}
column "email" {
null = false
type = text
}
primary_key {
columns = [column.id]
}
}
table "posts" {
schema = schema.public
annotation {
object_relationship {
name = "author"
using_fk_column = "author_id"
}
select_permission {
role = "user"
columns = ["id", "title", "author_id"]
allow_aggregations = false
filter {
column = "author_id"
op = "_eq"
value = "X-Hasura-User-Id"
}
}
}
column "id" {
null = false
type = int
}
column "title" {
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]
}
}
Step 3: Configure the Template Exporter
exporter "template" "hasura" {
template {
src = "templates/tables.yaml.tmpl"
name = "out/tables/tables.yaml"
}
template {
src = "templates/table.yaml.tmpl"
on "table" {
name = "out/tables/public_{{ .Name }}.yaml"
}
}
}
env "local" {
url = data.hcl_schema.app.url
export {
schema {
inspect = exporter.template.hasura
}
}
}
The template blocks define:
- A global template (
tables.yaml.tmpl) that produces atables.yamlfile listing all tracked tables. - A per-object template (
table.yaml.tmpl) with anon "table"block that produces one file per table, using{{ .Name }}in the output path.
Step 4: Write Templates
The global template generates Hasura's tables.yaml with !include directives pointing to per-table files:
{{- $s := index .Realm.Schemas 0 -}}
{{- range $t := $s.Tables }}
- '!include public_{{ $t.Name }}.yaml'
{{- end }}
The per-table template generates relationships and permissions using the attr function to access annotations:
{{- with $ann := attr .Object "annotation" -}}
table:
schema: {{ $.Object.Schema.Name }}
name: {{ $.Name }}
{{- with $ann.Block "object_relationship" }}
object_relationships:
{{- range . }}
- name: {{ .Attr "name" }}
using:
foreign_key_constraint_on: {{ .Attr "using_fk_column" }}
{{- end }}
{{- end }}
{{- with $ann.Block "array_relationship" }}
array_relationships:
{{- range . }}
- name: {{ .Attr "name" }}
using:
foreign_key_constraint_on:
column: {{ .Attr "foreign_column" }}
table:
schema: {{ $.Object.Schema.Name }}
name: {{ .Attr "foreign_table" }}
{{- end }}
{{- end }}
{{- with $ann.Block "select_permission" }}
select_permissions:
{{- range . }}
- role: {{ .Attr "role" }}
permission:
columns: [{{ range $i, $c := .Attr "columns" }}{{ if $i }}, {{ end }}{{ $c }}{{ end }}]
filter:
{{- range .Block "filter" }}
{{ .Attr "column" }}:
{{ .Attr "op" }}: {{ .Attr "value" }}
{{- end }}
allow_aggregations: {{ .Attr "allow_aggregations" }}
{{- end }}
{{- end }}
{{- end }}
Step 5: Run the Export
atlas schema inspect --env local --dev-url "docker://postgres/15" --export
This generates the following files:
- '!include public_posts.yaml'
- '!include public_users.yaml'
table:
schema: public
name: users
array_relationships:
- name: posts
using:
foreign_key_constraint_on:
column: author_id
table:
schema: public
name: posts
select_permissions:
- role: user
permission:
columns: [id, name, email]
filter:
id:
_eq: X-Hasura-User-Id
allow_aggregations: false
- role: admin
permission:
columns: [id, name, email]
filter:
id:
_ne: 0
allow_aggregations: true
table:
schema: public
name: posts
object_relationships:
- name: author
using:
foreign_key_constraint_on: author_id
select_permissions:
- role: user
permission:
columns: [id, title, author_id]
filter:
author_id:
_eq: X-Hasura-User-Id
allow_aggregations: false
Generating a GraphQL Schema
This example generates a GraphQL schema definition from your Atlas schema, including types, queries, and mutations.
Step 1: Define Annotation Schema
This example uses two annotation types, one for tables and one for columns:
data "hcl_schema" "app" {
path = "schema.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
}
}
}
Here we define:
annotation "table"attaches metadata to tables: atype_namefor the GraphQL type, adescription, and repeatablequery/mutationblocks that each accept nestedargblocks for field arguments.annotation "column"attaches metadata to columns: agraphql_typefor the SDL type, adescription, and an optionaldeprecatedreason.
Step 2: Annotate Schema Objects
schema "public" {}
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 = "name"
type = "String!"
}
arg {
name = "email"
type = "String!"
}
}
}
column "id" {
null = false
type = int
annotation {
graphql_type = "ID!"
description = "Unique identifier"
}
}
column "name" {
null = false
type = text
annotation {
graphql_type = "String!"
description = "Display name"
}
}
column "email" {
null = false
type = text
annotation {
graphql_type = "String!"
description = "Email address"
}
}
column "nickname" {
null = true
type = text
annotation {
graphql_type = "String"
description = "Legacy nickname field"
deprecated = "Use name instead"
}
}
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 = int
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
}
}
Step 3: Configure the Template Exporter
exporter "template" "graphql" {
template {
src = "templates/schema.graphql.tmpl"
name = "out/schema.graphql"
}
template {
src = "templates/type.graphql.tmpl"
on "table" {
name = "out/types/{{ .Name }}.graphql"
}
}
}
env "local" {
url = data.hcl_schema.app.url
export {
schema {
inspect = exporter.template.graphql
}
}
}
The template blocks define:
- A global template (
schema.graphql.tmpl) that produces a singleschema.graphqlfile containing all types, queries, and mutations. - A per-object template (
type.graphql.tmpl) with anon "table"block that produces one.graphqlfile per table, using{{ .Name }}in the output path.
Step 4: Write Templates
The global template generates the combined schema with Query and Mutation types:
{{- 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 generates an individual type definition for each table:
{{- 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 }}
Step 5: Run the Export
atlas schema inspect --env local --dev-url "docker://postgres/15" --export
This generates the following files:
"""
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!
"""
Display name
"""
name: String!
"""
Email address
"""
email: String!
"""
Legacy nickname field
"""
nickname: String @deprecated(reason: "Use name instead")
}
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(name: String!, email: String!): User!
}
"""
A registered user
"""
type User {
"""
Unique identifier
"""
id: ID!
"""
Display name
"""
name: String!
"""
Email address
"""
email: String!
"""
Legacy nickname field
"""
nickname: String @deprecated(reason: "Use name instead")
}
"""
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!
}
Generating OpenAPI (Swagger) Definitions
This example generates an OpenAPI 3.0 specification from your Atlas schema, including paths, operations, and component schemas.
Step 1: Define Annotation Schema
This example uses two annotation types, one for tables and one for columns:
data "hcl_schema" "app" {
path = "schema.hcl"
annotation "table" {
attr "path" {
type = string
}
attr "description" {
type = string
}
attr "tags" {
type = list(string)
}
block "operation" {
repeatable = true
attr "method" {
type = string
}
attr "summary" {
type = string
}
attr "operation_id" {
type = string
}
}
}
annotation "column" {
attr "description" {
type = string
}
attr "example" {
type = string
}
attr "format" {
type = string
}
}
}
Here we define:
annotation "table"attaches metadata to tables: apathfor the API endpoint, adescription,tagsfor grouping, and repeatableoperationblocks for each HTTP method.annotation "column"attaches metadata to columns: adescription, anexamplevalue, and an optionalformat(e.g.,email,date-time).
Step 2: Annotate Schema Objects
schema "public" {}
table "users" {
schema = schema.public
annotation {
path = "/users"
description = "List of users"
tags = ["users"]
operation {
method = "get"
summary = "List all users"
operation_id = "listUsers"
}
operation {
method = "post"
summary = "Create a user"
operation_id = "createUser"
}
}
column "id" {
null = false
type = int
annotation {
description = "User ID"
example = "1"
}
}
column "email" {
null = false
type = text
annotation {
description = "Email address"
example = "user@example.com"
format = "email"
}
}
column "created_at" {
null = false
type = timestamptz
annotation {
description = "Creation timestamp"
example = "2024-01-01T00:00:00Z"
format = "date-time"
}
}
primary_key {
columns = [column.id]
}
}
table "posts" {
schema = schema.public
annotation {
path = "/posts"
description = "List of posts"
tags = ["posts"]
operation {
method = "get"
summary = "List all posts"
operation_id = "listPosts"
}
}
column "id" {
null = false
type = int
annotation {
description = "Post ID"
example = "100"
}
}
column "title" {
null = false
type = text
annotation {
description = "Post title"
example = "Hello World"
}
}
column "author_id" {
null = false
type = int
annotation {
description = "Author user ID"
example = "1"
}
}
primary_key {
columns = [column.id]
}
foreign_key "fk_author" {
columns = [column.author_id]
ref_columns = [table.users.column.id]
on_delete = CASCADE
}
}
Step 3: Configure the Template Exporter
exporter "template" "openapi" {
template {
src = "templates/openapi.yaml.tmpl"
name = "out/openapi.yaml"
}
template {
src = "templates/schema.yaml.tmpl"
on "table" {
name = "out/schemas/{{ .Name }}.yaml"
}
}
}
env "local" {
url = data.hcl_schema.app.url
export {
schema {
inspect = exporter.template.openapi
}
}
}
The template blocks define:
- A global template (
openapi.yaml.tmpl) that produces a singleopenapi.yamlfile containing paths, operations, and component schemas. - A per-object template (
schema.yaml.tmpl) with anon "table"block that produces one schema file per table, using{{ .Name }}in the output path.
Step 4: Write Templates
The global template generates the combined OpenAPI spec:
openapi: 3.0.0
info:
title: Generated API
version: 1.0.0
paths:
{{- range $s := .Realm.Schemas }}
{{- range $t := $s.Tables }}
{{- with $ann := attr $t "annotation" }}
{{ $ann.Attr "path" }}:
{{- range $ann.Block "operation" }}
{{ .Attr "method" }}:
summary: {{ .Attr "summary" }}
operationId: {{ .Attr "operation_id" }}
tags: [{{ range $i, $tag := $ann.Attr "tags" }}{{ if $i }}, {{ end }}{{ $tag }}{{ end }}]
responses:
'200':
description: {{ $ann.Attr "description" }}
content:
application/json:
schema:
$ref: '#/components/schemas/{{ $t.Name }}'
{{- end }}
{{- end }}
{{- end }}
{{- end }}
components:
schemas:
{{- range $s := .Realm.Schemas }}
{{- range $t := $s.Tables }}
{{ $t.Name }}:
type: object
properties:
{{- range $c := $t.Columns }}
{{ $c.Name }}:
{{- with $cann := attr $c "annotation" }}
description: {{ $cann.Attr "description" }}
example: {{ $cann.Attr "example" }}
{{- with $cann.Attr "format" }}
format: {{ . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
The per-table template generates an individual component schema for each table:
{{- with $ann := attr .Object "annotation" -}}
{{ $.Name }}:
type: object
description: {{ $ann.Attr "description" }}
properties:
{{- range $c := $.Object.Columns }}
{{- with $cann := attr $c "annotation" }}
{{ $c.Name }}:
description: {{ $cann.Attr "description" }}
example: {{ $cann.Attr "example" }}
{{- with $cann.Attr "format" }}
format: {{ . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
Step 5: Run the Export
atlas schema inspect --env local --dev-url "docker://postgres/15" --export
This generates the following files:
openapi: 3.0.0
info:
title: Generated API
version: 1.0.0
paths:
/posts:
get:
summary: List all posts
operationId: listPosts
tags: [posts]
responses:
'200':
description: List of posts
content:
application/json:
schema:
$ref: '#/components/schemas/posts'
/users:
get:
summary: List all users
operationId: listUsers
tags: [users]
responses:
'200':
description: List of users
content:
application/json:
schema:
$ref: '#/components/schemas/users'
post:
summary: Create a user
operationId: createUser
tags: [users]
responses:
'200':
description: List of users
content:
application/json:
schema:
$ref: '#/components/schemas/users'
components:
schemas:
posts:
type: object
properties:
id:
description: Post ID
example: 100
title:
description: Post title
example: Hello World
author_id:
description: Author user ID
example: 1
users:
type: object
properties:
id:
description: User ID
example: 1
email:
description: Email address
example: user@example.com
format: email
created_at:
description: Creation timestamp
example: 2024-01-01T00:00:00Z
format: date-time
users:
type: object
description: List of users
properties:
id:
description: User ID
example: 1
email:
description: Email address
example: user@example.com
format: email
created_at:
description: Creation timestamp
example: 2024-01-01T00:00:00Z
format: date-time
posts:
type: object
description: List of posts
properties:
id:
description: Post ID
example: 100
title:
description: Post title
example: Hello World
author_id:
description: Author user ID
example: 1
Template API Reference
Accessing Annotations
Use the attr function to access annotations on schema objects:
| Expression | Description |
|---|---|
attr $t "annotation" | Get the annotation block attached to table $t |
$ann.Attr "name" | Get a scalar attribute value from an annotation block |
$ann.Block "block_name" | Get all nested blocks of the given type (returns a slice) |
.Block "nested" | Get nested blocks within a repeatable block |
Template Context
Templates receive different context depending on their configuration:
| Configuration | Context Object | Fields |
|---|---|---|
Global template (no on block) | SchemaInspect | .URL, .Realm |
Per-object template (on "table") | Object wrapper | .Object (the table), .Name (table name) |
Using format.schema.inspect vs exporter "template"
There are two ways to use templates for schema inspection output:
| Approach | Use Case |
|---|---|
format { schema { inspect = file("tmpl") } } | Output to stdout (single template) |
exporter "template" "name" { ... } | Generate multiple files with --export flag |
The format approach is simpler when you need a single output written to stdout.
The exporter approach is more powerful when you need to generate a directory structure with multiple files.