NexusCS

Phoenix Ecto 1.3

Elixir
Quick reference for Phoenix 1.3 with Ecto 2.x - legacy database integration for older Phoenix applications. Use Phoenix 1.7+ for new projects.
phoenix
ecto
database
orm
legacy

⚠️ Legacy Version Warning

This cheatsheet covers Phoenix 1.3 with Ecto 2.x (phoenix_ecto 1.3). This is a legacy stack for older Phoenix applications. For new projects, use Phoenix 1.7+ with Ecto 3.x.

Getting started

Introduction

Phoenix Ecto 1.3 integrates Ecto 2.x database wrapper with Phoenix 1.3 web framework. Provides repository pattern, schema definitions, changesets, and query DSL.

Installation

# mix.exs
def deps do
  [
    {:phoenix, "~> 1.3.0"},
    {:phoenix_ecto, "~> 1.3"},
    {:ecto, "~> 2.2"},
    {:postgrex, ">= 0.0.0"}  # or {:mariaex, ">= 0.0.0"}
  ]
end

Version Compatibility

Package Version Release Date
Phoenix 1.3.x 2017
phoenix_ecto 1.3.x 2017
Ecto 2.2.x 2017
Elixir 1.4+ Required

Quick Example

# Define schema
defmodule MyApp.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :age, :integer
    timestamps()
  end
end

# Query data
import Ecto.Query
alias MyApp.Repo

Repo.all(from u in User, where: u.age > 18)

Configuration

Repo Configuration

# config/config.exs
config :my_app, MyApp.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "my_app_dev",
  hostname: "localhost",
  pool_size: 10

Environment-Specific Config

# config/dev.exs
config :my_app, MyApp.Repo,
  database: "my_app_dev",
  pool_size: 10

# config/test.exs
config :my_app, MyApp.Repo,
  database: "my_app_test",
  pool: Ecto.Adapters.SQL.Sandbox

# config/prod.secret.exs
config :my_app, MyApp.Repo,
  url: System.get_env("DATABASE_URL"),
  pool_size: 20

Repo Module Definition

# lib/my_app/repo.ex
defmodule MyApp.Repo do
  use Ecto.Repo, otp_app: :my_app

  @doc """
  Dynamically loads the repository url from the
  DATABASE_URL environment variable.
  """
  def init(_, opts) do
    {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
  end
end

Supervision Tree

# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Start the Ecto repository
      MyApp.Repo,
      # Start the endpoint
      MyAppWeb.Endpoint
    ]

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

Schema & Changeset

Schema Definition

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer
    field :bio, :string
    field :admin, :boolean, default: false

    has_many :posts, MyApp.Blog.Post
    belongs_to :company, MyApp.Accounts.Company

    timestamps()  # Adds inserted_at and updated_at
  end
end

Field Types

Type Description
:string UTF-8 string
:integer Integer number
:float Float number
:boolean Boolean true/false
:binary Binary data
:date Date struct
:time Time struct
:naive_datetime DateTime without TZ
:utc_datetime DateTime with UTC
:decimal Arbitrary precision
:map Embedded map
`` Array of type

Basic Changeset

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email, :age, :bio])
  |> validate_required([:name, :email])
  |> validate_length(:name, min: 2, max: 100)
  |> validate_format(:email, ~r/@/)
  |> validate_number(:age, greater_than: 0)
  |> unique_constraint(:email)
end

Validations

# Required fields
|> validate_required([:name, :email])

# Length validation
|> validate_length(:name, min: 2, max: 100)
|> validate_length(:bio, max: 1000)

# Format validation (regex)
|> validate_format(:email, ~r/@/)

# Number validation
|> validate_number(:age, greater_than: 0)
|> validate_number(:age, less_than: 150)

# Inclusion validation
|> validate_inclusion(:role, ["admin", "user", "moderator"])

# Exclusion validation
|> validate_exclusion(:username, ["admin", "root"])

# Confirmation validation (password confirmation)
|> validate_confirmation(:password)

# Acceptance validation (terms of service)
|> validate_acceptance(:terms_of_service)

Database Constraints

# Unique constraint
|> unique_constraint(:email)
|> unique_constraint(:username, name: :users_username_index)

# Foreign key constraint
|> foreign_key_constraint(:company_id)

# Check constraint
|> check_constraint(:age, name: :age_must_be_positive)

# No assoc constraint (prevents delete if associations exist)
|> no_assoc_constraint(:posts)

Casting Associations

def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email])
  |> cast_assoc(:posts, required: true)
  |> cast_embed(:address)
end

Repository Operations

Insert

# With struct
user = %User{name: "John", email: "john@example.com"}
Repo.insert(user)

# With changeset
changeset = User.changeset(%User{}, params)
Repo.insert(changeset)

# Insert all (bulk insert)
users = [
  %{name: "Alice", email: "alice@example.com"},
  %{name: "Bob", email: "bob@example.com"}
]
Repo.insert_all(User, users)

Update

# Update with changeset
user = Repo.get(User, 1)
changeset = User.changeset(user, %{name: "Jane"})
Repo.update(changeset)

# Update all (bulk update)
from(u in User, where: u.age < 18)
|> Repo.update_all(set: [minor: true])

Delete

# Delete single record
user = Repo.get(User, 1)
Repo.delete(user)

# Delete with changeset
changeset = User.changeset(user, %{})
Repo.delete(changeset)

# Delete all (bulk delete)
from(u in User, where: u.active == false)
|> Repo.delete_all()

Fetch Operations

# Get by ID (raises if not found)
Repo.get!(User, 1)

# Get by ID (returns nil if not found)
Repo.get(User, 1)

# Get by field
Repo.get_by(User, email: "john@example.com")

# Fetch all
Repo.all(User)

# Fetch one
Repo.one(from u in User, where: u.id == 1)

Query Execution

# All records
query = from u in User, where: u.active == true
Repo.all(query)

# One record (raises if not exactly one)
Repo.one!(query)

# Aggregate functions
Repo.aggregate(User, :count, :id)
Repo.aggregate(User, :avg, :age)
Repo.aggregate(User, :sum, :points)

Preloading

# Preload single association
user = Repo.get(User, 1) |> Repo.preload(:posts)

# Preload multiple associations
user = Repo.get(User, 1) |> Repo.preload([:posts, :company])

# Nested preloading
user = Repo.get(User, 1) |> Repo.preload([posts: :comments])

# Preload with query
posts_query = from p in Post, where: p.published == true
user = Repo.get(User, 1) |> Repo.preload(posts: posts_query)

Query DSL

Basic Query Syntax

import Ecto.Query
alias MyApp.Repo

# Keyword syntax
query = from u in User,
        where: u.age > 18,
        select: u

Repo.all(query)

# Pipe syntax
User
|> where([u], u.age > 18)
|> select([u], u)
|> Repo.all()

Where Clauses

# Simple where
from u in User, where: u.age > 18

# Multiple conditions (AND)
from u in User, where: u.age > 18 and u.active == true

# OR conditions
from u in User, where: u.admin == true or u.moderator == true

# IN operator
from u in User, where: u.role in ["admin", "moderator"]

# LIKE operator
from u in User, where: like(u.name, "%John%")

# IS NULL
from u in User, where: is_nil(u.deleted_at)

# Fragment for raw SQL
from u in User, where: fragment("lower(?)", u.email) == "john@example.com"

Select Operations

# Select entire struct
from u in User, select: u

# Select specific fields
from u in User, select: {u.id, u.name}

# Select map
from u in User, select: %{id: u.id, name: u.name}

# Select struct
from u in User, select: %User{id: u.id, name: u.name}

# Select with computed fields
from u in User, select: %{
  id: u.id,
  full_name: fragment("? || ' ' || ?", u.first_name, u.last_name)
}

Ordering & Limiting

# Order by
from u in User, order_by: u.name
from u in User, order_by: [desc: u.inserted_at]

# Multiple order by
from u in User, order_by: [u.last_name, u.first_name]

# Limit and offset
from u in User, limit: 10
from u in User, limit: 10, offset: 20

# First and last
User |> first() |> Repo.one()
User |> last() |> Repo.one()

Joins

# Inner join
from u in User,
  join: p in Post, on: p.user_id == u.id,
  select: {u, p}

# Left join
from u in User,
  left_join: p in Post, on: p.user_id == u.id,
  select: {u, p}

# Join with associations
from u in User,
  join: p in assoc(u, :posts),
  select: {u, p}

# Multiple joins
from u in User,
  join: p in assoc(u, :posts),
  join: c in assoc(p, :comments),
  select: {u, p, c}

Aggregations

# Count
from u in User, select: count(u.id)

# Sum
from o in Order, select: sum(o.amount)

# Average
from u in User, select: avg(u.age)

# Min and Max
from p in Product, select: {min(p.price), max(p.price)}

# Group by
from o in Order,
  group_by: o.user_id,
  select: {o.user_id, sum(o.amount)}

Dynamic Queries

def filter_users(params) do
  User
  |> apply_filters(params)
  |> Repo.all()
end

defp apply_filters(query, params) do
  query
  |> filter_by_name(params["name"])
  |> filter_by_age(params["age"])
end

defp filter_by_name(query, nil), do: query
defp filter_by_name(query, name) do
  where(query, [u], like(u.name, ^"%#{name}%"))
end

defp filter_by_age(query, nil), do: query
defp filter_by_age(query, age) do
  where(query, [u], u.age >= ^age)
end

Associations

Has Many

# Schema definition
defmodule MyApp.Accounts.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    has_many :posts, MyApp.Blog.Post
    timestamps()
  end
end

defmodule MyApp.Blog.Post do
  use Ecto.Schema

  schema "posts" do
    field :title, :string
    belongs_to :user, MyApp.Accounts.User
    timestamps()
  end
end

Belongs To

# Foreign key defaults to :user_id
belongs_to :user, MyApp.Accounts.User

# Custom foreign key
belongs_to :author, MyApp.Accounts.User, foreign_key: :author_id

# With on_replace option
belongs_to :company, MyApp.Accounts.Company, on_replace: :nilify

Many to Many

# Schema definition
defmodule MyApp.Blog.Post do
  use Ecto.Schema

  schema "posts" do
    field :title, :string
    many_to_many :tags, MyApp.Blog.Tag, join_through: "posts_tags"
    timestamps()
  end
end

defmodule MyApp.Blog.Tag do
  use Ecto.Schema

  schema "tags" do
    field :name, :string
    many_to_many :posts, MyApp.Blog.Post, join_through: "posts_tags"
    timestamps()
  end
end

Preloading Patterns

# Simple preload
user = Repo.get(User, 1) |> Repo.preload(:posts)

# Multiple associations
user = Repo.get(User, 1) |> Repo.preload([:posts, :comments])

# Nested preloading
user = Repo.get(User, 1)
  |> Repo.preload([posts: [:comments, :tags]])

# Preload with custom query
posts_query = from p in Post, where: p.published == true, order_by: [desc: p.inserted_at]
user = Repo.get(User, 1) |> Repo.preload(posts: posts_query)

# Preload in query
from u in User,
  where: u.id == ^user_id,
  preload: [:posts, :comments]

Building Associations

# Build association (not persisted)
user = Repo.get(User, 1)
post = Ecto.build_assoc(user, :posts, %{title: "Hello"})

# Insert with association
changeset = User.changeset(%User{}, user_params)
  |> Ecto.Changeset.put_assoc(:posts, [post1, post2])
Repo.insert(changeset)

# Cast association
def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email])
  |> cast_assoc(:posts)
end

Forms Integration

Controller Pattern

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  alias MyApp.Accounts
  alias MyApp.Accounts.User

  def new(conn, _params) do
    changeset = Accounts.change_user(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: Routes.user_path(conn, :show, user))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def edit(conn, %{"id" => id}) do
    user = Accounts.get_user!(id)
    changeset = Accounts.change_user(user)
    render(conn, "edit.html", user: user, changeset: changeset)
  end

  def update(conn, %{"id" => id, "user" => user_params}) do
    user = Accounts.get_user!(id)

    case Accounts.update_user(user, user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User updated successfully.")
        |> redirect(to: Routes.user_path(conn, :show, user))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", user: user, changeset: changeset)
    end
  end
end

Context Pattern (Phoenix 1.3)

defmodule MyApp.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias MyApp.Repo
  alias MyApp.Accounts.User

  def list_users do
    Repo.all(User)
  end

  def get_user!(id), do: Repo.get!(User, id)

  def create_user(attrs \\ %{}) do
    %User{}
    |> User.changeset(attrs)
    |> Repo.insert()
  end

  def update_user(%User{} = user, attrs) do
    user
    |> User.changeset(attrs)
    |> Repo.update()
  end

  def delete_user(%User{} = user) do
    Repo.delete(user)
  end

  def change_user(%User{} = user) do
    User.changeset(user, %{})
  end
end

Form Template

# lib/my_app_web/templates/user/form.html.eex
<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <%= label f, :name %>
  <%= text_input f, :name %>
  <%= error_tag f, :name %>

  <%= label f, :email %>
  <%= email_input f, :email %>
  <%= error_tag f, :email %>

  <%= label f, :age %>
  <%= number_input f, :age %>
  <%= error_tag f, :age %>

  <%= submit "Submit" %>
<% end %>

Error Helpers

# lib/my_app_web/views/error_helpers.ex
defmodule MyAppWeb.ErrorHelpers do
  use Phoenix.HTML

  def error_tag(form, field) do
    Enum.map(Keyword.get_values(form.errors, field), fn error ->
      content_tag(:span, translate_error(error), class: "help-block")
    end)
  end

  def translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", to_string(value))
    end)
  end
end

Testing

Test Configuration

# config/test.exs
config :my_app, MyApp.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "my_app_test",
  hostname: "localhost",
  pool: Ecto.Adapters.SQL.Sandbox

Test Helper Setup

# test/test_helper.exs
ExUnit.start()

Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)

DataCase Pattern

# test/support/data_case.ex
defmodule MyApp.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias MyApp.Repo
      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import MyApp.DataCase
    end
  end

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
    end

    :ok
  end
end

ConnCase for Integration Tests

# test/support/conn_case.ex
defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use Phoenix.ConnTest
      alias MyAppWeb.Router.Helpers, as: Routes
      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
    end

    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

Unit Test Example

defmodule MyApp.AccountsTest do
  use MyApp.DataCase
  alias MyApp.Accounts

  describe "users" do
    @valid_attrs %{name: "John Doe", email: "john@example.com", age: 30}
    @invalid_attrs %{name: nil, email: nil}

    test "list_users/0 returns all users" do
      user = user_fixture()
      assert Accounts.list_users() == [user]
    end

    test "create_user/1 with valid data creates a user" do
      assert {:ok, user} = Accounts.create_user(@valid_attrs)
      assert user.name == "John Doe"
      assert user.email == "john@example.com"
    end

    test "create_user/1 with invalid data returns error changeset" do
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(@invalid_attrs)
    end
  end

  defp user_fixture(attrs \\ %{}) do
    {:ok, user} =
      attrs
      |> Enum.into(@valid_attrs)
      |> Accounts.create_user()

    user
  end
end

Integration Test Example

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase
  alias MyApp.Accounts

  @create_attrs %{name: "John Doe", email: "john@example.com", age: 30}
  @invalid_attrs %{name: nil, email: nil}

  test "renders form for new user", %{conn: conn} do
    conn = get(conn, Routes.user_path(conn, :new))
    assert html_response(conn, 200) =~ "New User"
  end

  test "creates user when data is valid", %{conn: conn} do
    conn = post(conn, Routes.user_path(conn, :create), user: @create_attrs)
    assert %{id: id} = redirected_params(conn)
    assert redirected_to(conn) == Routes.user_path(conn, :show, id)

    conn = get(conn, Routes.user_path(conn, :show, id))
    assert html_response(conn, 200) =~ "John Doe"
  end

  test "renders errors when data is invalid", %{conn: conn} do
    conn = post(conn, Routes.user_path(conn, :create), user: @invalid_attrs)
    assert html_response(conn, 200) =~ "New User"
  end
end

Transactions

Basic Transaction

Repo.transaction(fn ->
  user = Repo.insert!(%User{name: "John"})
  Repo.insert!(%Post{title: "Hello", user_id: user.id})
end)

# Returns {:ok, result} or {:error, reason}

Transaction with Rollback

Repo.transaction(fn ->
  user = Repo.insert!(%User{name: "John"})

  if some_condition do
    Repo.rollback(:some_reason)
  end

  Repo.insert!(%Post{title: "Hello", user_id: user.id})
end)

Multi (Composable Transactions)

alias Ecto.Multi

Multi.new()
|> Multi.insert(:user, User.changeset(%User{}, user_params))
|> Multi.run(:post, fn %{user: user} ->
  %Post{user_id: user.id}
  |> Post.changeset(post_params)
  |> Repo.insert()
end)
|> Multi.run(:notification, fn %{user: user, post: post} ->
  send_notification(user, post)
  {:ok, :sent}
end)
|> Repo.transaction()

# Returns {:ok, %{user: user, post: post, notification: :sent}}
# or {:error, :user, changeset, %{}}

Handling Multi Results

case Repo.transaction(multi) do
  {:ok, %{user: user, post: post}} ->
    {:ok, user, post}

  {:error, :user, changeset, _} ->
    {:error, :user_creation_failed, changeset}

  {:error, :post, changeset, %{user: user}} ->
    {:error, :post_creation_failed, changeset}
end

Nested Transactions

# Note: Ecto 2.x uses savepoints for nested transactions
Repo.transaction(fn ->
  user = Repo.insert!(%User{name: "John"})

  Repo.transaction(fn ->
    Repo.insert!(%Post{title: "Hello", user_id: user.id})
  end)

  user
end)

Mix Tasks

Database Tasks

# Create database
mix ecto.create

# Drop database
mix ecto.drop

# Migrate database
mix ecto.migrate

# Rollback migration
mix ecto.rollback

# Reset database (drop, create, migrate)
mix ecto.reset

# Check migration status
mix ecto.migrations

Migration Tasks

# Generate migration
mix ecto.gen.migration create_users

# Rollback last migration
mix ecto.rollback

# Rollback to specific version
mix ecto.rollback --to 20170101000000

# Rollback N migrations
mix ecto.rollback --step 3

Schema Generation

# Generate schema and migration
mix phx.gen.schema Accounts.User users name:string email:string:unique age:integer

# Generate context with CRUD
mix phx.gen.context Accounts User users name:string email:string age:integer

# Generate JSON API
mix phx.gen.json Accounts User users name:string email:string age:integer

# Generate HTML scaffold
mix phx.gen.html Accounts User users name:string email:string age:integer

Seed Data

# priv/repo/seeds.exs
alias MyApp.Repo
alias MyApp.Accounts.User

Repo.insert!(%User{
  name: "John Doe",
  email: "john@example.com",
  age: 30
})

# Run seeds
# mix run priv/repo/seeds.exs

Custom Mix Task

# lib/mix/tasks/my_app.some_task.ex
defmodule Mix.Tasks.MyApp.SomeTask do
  use Mix.Task
  alias MyApp.Repo

  @shortdoc "Description of task"
  def run(_) do
    Mix.Task.run("app.start")
    # Your code here
  end
end

Gotchas

Changesets Aren't Persisted

# ❌ WRONG - changeset not persisted
changeset = User.changeset(%User{}, params)
# User not saved!

# ✅ CORRECT - insert changeset
{:ok, user} = Repo.insert(changeset)

Preloading in Queries vs After

# ⚠️ N+1 Query Problem
users = Repo.all(User)
for user <- users do
  user.posts  # Triggers separate query for each user!
end

# ✅ CORRECT - Preload to avoid N+1
users = Repo.all(User) |> Repo.preload(:posts)
for user <- users do
  user.posts  # Already loaded!
end

# ✅ CORRECT - Preload in query
users = from(u in User, preload: [:posts]) |> Repo.all()

Struct vs Changeset

# ❌ WRONG - Structs don't validate
Repo.insert(%User{name: "x", email: "invalid"})

# ✅ CORRECT - Use changeset for validation
%User{}
|> User.changeset(%{name: "x", email: "invalid"})
|> Repo.insert()

Updating Associations

# ❌ WRONG - Can't update through parent
user = Repo.get(User, 1) |> Repo.preload(:posts)
user.posts = [new_post]
Repo.update(user)  # Does nothing!

# ✅ CORRECT - Use put_assoc or cast_assoc
changeset = User.changeset(user, %{})
  |> Ecto.Changeset.put_assoc(:posts, [new_post])
Repo.update(changeset)

Transaction Rollbacks

# ❌ WRONG - Exceptions don't rollback
Repo.transaction(fn ->
  Repo.insert!(%User{name: "John"})
  raise "Error!"  # Transaction committed before raise!
end)

# ✅ CORRECT - Use Repo.rollback
Repo.transaction(fn ->
  Repo.insert!(%User{name: "John"})
  if error_condition do
    Repo.rollback(:error_reason)
  end
end)

Unique Constraints Must Exist

# ❌ WRONG - Constraint doesn't exist in DB
def changeset(user, attrs) do
  user
  |> cast(attrs, [:email])
  |> unique_constraint(:email)  # Will raise if no DB constraint!
end

# ✅ CORRECT - Create index in migration first
# priv/repo/migrations/xxx_create_users.exs
create unique_index(:users, [:email])

Cast vs Put

# cast - For external, untrusted data (user input)
def changeset(user, attrs) do
  user
  |> cast(attrs, [:name, :email])  # Only allow specific fields
end

# put_change - For internal, trusted data
def internal_update(changeset) do
  changeset
  |> put_change(:admin, true)  # Set internal field
end

Timestamps Must Be Declared

# ❌ WRONG - timestamps() not called
schema "users" do
  field :name, :string
  # Missing timestamps()!
end

# ✅ CORRECT - Call timestamps() in schema
schema "users" do
  field :name, :string
  timestamps()  # Adds inserted_at and updated_at
end

Also see