PostgreSQL Data Layer Minimal Setup

The fastest path to a minimal Ash + AshPostgres setup is igniter. It scaffolds the app, pulls the right deps, wires up App.Repo, creates config/config.exs with the ash_domains entry, and generates the Postgres config for every environment.

$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash,ash_postgres --yes
$ cd app
Looking for an Ash + Phoenix Framework setup instead? Jump to PostgreSQL Data Layer Ash + Phoenix Framework Setup.

What Igniter gave you

The installer creates the AshPostgres.Repo wrapper:

lib/app/repo.ex
defmodule App.Repo do
  use AshPostgres.Repo, otp_app: :app
end

A supervised application tree that starts the repo on boot:

lib/app/application.ex
defmodule App.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [App.Repo]

    opts = [strategy: :one_for_one, name: App.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

And the config files, with the ecto_repos entry already in place:

config/config.exs
import Config

config :app, :ash_domains, [App.Shop] (1)
config :app, ecto_repos: [App.Repo]

import_config "#{config_env()}.exs"
1 You add this line yourself once the App.Shop domain exists, see below.
config/dev.exs
import Config

config :app, App.Repo,
  username: "postgres", (1)
  password: "postgres",
  hostname: "localhost",
  database: "app_dev", (2)
  port: 5432,
  show_sensitive_data_on_connection_error: true,
  pool_size: 10
1 Most developers use postgres:postgres for local development. Adjust to whatever your PostgreSQL is expecting.
2 Pick a database name that matches your app.
config/runtime.exs
import Config

if config_env() == :prod do
  database_url =
    System.get_env("DATABASE_URL") || (1)
      raise """
      environment variable DATABASE_URL is missing.
      For example: ecto://USER:PASS@HOST/DATABASE
      """

  config :app, App.Repo,
    url: database_url,
    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
end
1 Most production environments use environment variables to configure the database connection. Heroku, Fly, and similar cloud providers all default to DATABASE_URL.
config/test.exs
import Config

config :app, App.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "app_test#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: 10

Add AshPostgres to a Resource

As an example we add a minimal Product resource to our application. The resource is more or less empty. We add more attributes later.

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer, (1)
    domain: App.Shop,
    otp_app: :app

  postgres do
    table "products" (2)
    repo App.Repo
  end

  attributes do
    uuid_primary_key :id (3)
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end
1 Tells Ash to use the AshPostgres.DataLayer for this resource.
2 Sets the name of the table in the database.
3 An AshPostgres resource always has to have at least one primary key attribute.

And the domain that owns it, with the code interfaces we will call from IEx:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :list_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
  end
end

The products table is not yet created. Not even the database is created. We do that in the next step.

Create the Database

Assuming that you have PostgreSQL installed and running on your system, you can now create the database with the mix ash_postgres.create command:

$ mix ash_postgres.create
The database for App.Repo has been created

Drop the Database

In case you need to drop (delete) the database you can use the mix ash_postgres.drop command:

$ mix ash_postgres.drop
The database for App.Repo has been dropped
Please re-run mix ash_postgres.create now in case you ran the drop command by accident while working through this tutorial.

mix ash.codegen

mix ash.codegen scans your application for resources, keeps track of them and generates migrations if things (e.g. attributes) change. It takes a migration name as an argument so the generated file is easy to read in a pull request.

We created the database but it is still empty. It is time to use mix ash.codegen to create a migration for the Product resource:

$ mix ash.codegen create_products
Running codegen for AshPostgres.DataLayer...

Extension Migrations:
No extensions to install

Generating Tenant Migrations:

Generating Migrations:
* creating priv/repo/migrations/20260418183000_create_products.exs

It is not a bad habit to check the generated migration file before running the migration. In our case it looks like this:

[...]
  def up do
    create table(:products, primary_key: false) do (1)
      add :id, :uuid, null: false, primary_key: true (2)
    end
  end

  def down do
    drop table(:products)
  end
[...]
1 Create a table named products.
2 Add a primary key column named id of type uuid.

mix ash_postgres.migrate

Now it is time to run the migration:

$ mix ash_postgres.migrate

[info] == Running 20260418183000 App.Repo.Migrations.CreateProducts.up/0 forward
[info] create table products
[info] == Migrated 20260418183000 in 0.0s

If you want to you can check the table with psql:

$ psql -h localhost -U postgres -d app_dev -c "\d products"

            Table "public.products"
 Column | Type | Collation | Nullable | Default
--------+------+-----------+----------+---------
 id     | uuid |           | not null |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

mix ash_postgres.rollback

Sometimes you want to undo a migration. You can do that with mix ash_postgres.rollback:

$ mix ash_postgres.rollback

[info] == Running 20260418183000 App.Repo.Migrations.CreateProducts.down/0 forward
[info] drop table products
[info] == Migrated 20260418183000 in 0.0s
In case you just did a rollback in this example you want to migrate again with mix ash_postgres.migrate before you continue.

Add Attributes to a Resource

Let’s add two attributes to the Product resource, plus a matching get_product_by_name code interface:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    domain: App.Shop,
    otp_app: :app

  postgres do
    table "products"
    repo App.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true (1)
    attribute :price, :decimal, public?: true (2)
  end

  actions do
    default_accept [:name, :price]
    defaults [:create, :read, :update, :destroy]
  end
end
1 A :name attribute of type :string.
2 A :price attribute of type :decimal.
lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :list_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name (1)
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
  end
end
1 A get_product_by_name helper that looks up a product by its :name.

Start mix ash.codegen again:

$ mix ash.codegen add_product_attributes
Running codegen for AshPostgres.DataLayer...

Extension Migrations:
No extensions to install

Generating Tenant Migrations:

Generating Migrations:
* creating priv/repo/migrations/20260418184000_add_product_attributes.exs (1)
$ mix ash_postgres.migrate (2)

[info] == Running 20260418184000 App.Repo.Migrations.AddProductAttributes.up/0 forward
[info] alter table products
[info] == Migrated 20260418184000 in 0.0s
1 mix ash.codegen created a new migration file which includes the new attributes.
2 mix ash_postgres.migrate runs the migration.

Because we are curious we check the table again:

$ psql -h localhost -U postgres -d app_dev -c "\d products"

              Table "public.products"
 Column |  Type   | Collation | Nullable | Default
--------+---------+-----------+----------+---------
 id     | uuid    |           | not null |
 name   | text    |           |          |
 price  | numeric |           |          |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

Time to add two entries into the products table (a Banana and a Pineapple):

$ iex -S mix
iex(1)> App.Shop.create_product!(%{name: "Banana", price: Decimal.new("0.10")})

[debug] QUERY OK db=12.5ms idle=795.4ms
begin []

[debug] QUERY OK db=3.9ms
INSERT INTO "products" ("id","name","price") VALUES ($1,$2,$3)
RETURNING "price","name","id" ["4d7e383b-ce7b-44d0-818c-290eaa8b0532",
"Banana", Decimal.new("0.1")] (1)

[debug] QUERY OK db=0.8ms
commit []
%App.Shop.Product{
  __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
  id: "4d7e383b-ce7b-44d0-818c-290eaa8b0532",
  name: "Banana",
  price: Decimal.new("0.1")
}
iex(2)> App.Shop.create_product!(%{name: "Pineapple", price: Decimal.new("0.50")})

[debug] QUERY OK db=0.3ms idle=935.2ms
begin []

[debug] QUERY OK db=0.6ms
INSERT INTO "products" ("id","name","price") VALUES ($1,$2,$3)
RETURNING "price","name","id" ["e854a911-4cda-4693-bd49-db200b675ded",
"Pineapple", Decimal.new("0.5")]

[debug] QUERY OK db=0.6ms
commit []
%App.Shop.Product{
  __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
  id: "e854a911-4cda-4693-bd49-db200b675ded",
  name: "Pineapple",
  price: Decimal.new("0.5")
}
iex(3)>
1 In development mode you see these SQL debugging messages.

After pressing Ctrl-C two times to exit the iex session we can check the table again:

$ psql -h localhost -U postgres -d app_dev -c "select * from products"
                  id                  |   name    | price
--------------------------------------+-----------+-------
 4d7e383b-ce7b-44d0-818c-290eaa8b0532 | Banana    |  0.10
 e854a911-4cda-4693-bd49-db200b675ded | Pineapple |  0.50
(2 rows)

Congratulations! You just created your first Ash application with a PostgreSQL database.

Never forget to run mix ash.codegen <migration_name> and mix ash_postgres.migrate after you change a resource. Otherwise the changes will not be reflected in the database.