Published on

When to use field :default in an Ecto schema

What does the :default option actually do when you set it on a field in an Ecto schema? This question came up when dealing with this scenario:

defmodule MyApp.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def up do
    create table(:posts) do
      add :category, :string

      timestamps()
    end
  end
end
defmodule MyApp.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :category, :string, default: "all"

    timestamps()
  end

  def changeset(changeset, attrs) do
    changeset
    |> cast(attrs, [:category])
  end
end

A :default option is set on the schema field, but not in the migration column. This irked me. What does that mean? What is the guarantee here?

I needed to dig deeper to understand how :default is used. According to the Ecto docs for Ecto.Schema.field/3, :default sets the default value on the schema and the struct. Sounds like it’s just a defstruct call. Going into the bowels of Ecto I found out that was correct.

The field :default is used in the @ecto_struct_fields compile-time attribute. @ecto_struct_fields is a list of {key, default_or_assoc} tuples that’s used in a defstruct call like so:

defstruct Enum.reverse(@ecto_struct_fields)

This means that setting the :default option on a field is identical to this:

defmodule Post do
  defstruct category: "all"
end

What’s the difference between this and setting the :default option for Ecto.Migration.add/3?

When you set the :default option in the migration it will be used in the SQL as DEFAULT for the column definition like so:

CREATE TABLE posts
(
  'category' varchar(255) SET DEFAULT 'all'
);

Now let’s think of the consequences. What happens if we batch insert with Ecto.Repo.insert_all/3?

iex(1)> now = %{NaiveDateTime.utc_now() | microsecond: {0, 0}}
~N[2023-08-06 14:25:42]

iex(2)> MyApp.Repo.insert_all(MyApp.Posts.Post, [%{inserted_at: now, updated_at: now}])
{1, nil}
iex(3)> MyApp.Repo.all(MyApp.Posts.Post)
[
  %MyApp.Posts.Post{
    __meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
    id: 1,
    category: nil,
    inserted_at: ~N[2023-08-06 14:25:42],
    updated_at: ~N[2023-08-06 14:25:42]
  }
]

Oh-uh, the category is nil! This makes sense as :default is only part of the struct, and the struct fields will be overridden with the values from the database when loaded. Thus :default is only used when we initialize a new struct.

One of the wonderful things about Ecto is how it makes developers let the database deal with what should be database concerns (for example Ecto.Changeset.unique_constraint/3). I think the same should be said for the :default option. Just let the database deal with it!

Let’s ask ourselves:

  • Should it be possible to have a nil value on a field? If not, set null: false in the migration.

  • Is there an expectation of a default value in the database? If yes, set default: "value" in the migration.

  • When should you use the :default option on the schema field?

    If you always require a value (ensured with a NOT NULL constraint in the database) and need some default value before running through the changeset. But even in that case, I would ask if there is a more explicit way to handle that, for example in the context function:

    defmodule MyApp.Posts do
      alias MyApp.{Posts.Post, Repo}
    
      @default_category "all"
    
      def create_post(attrs) do
        %Post{category: @default_category}
        |> Post.changeset(attrs)
        |> Repo.insert()
      end
    end

Embedded schema

Brian Underwood points out that one of the main reasons for :default existing could be database-less schemas:

@danschultzer Just read your post about Ecto defaults. I’ll bet that one of the main reasons why `default` exists is for when you’re using Ecto without a database since people often use Ecto for validating / transforming data from various sources without necessarily storing it.

A database-less schema is normally kept in memory using Ecto.Schema.embedded_schema/1:

defmodule MyApp.Posts.Post do
  use Ecto.Schema

  embedded_schema do
    field :category, :string, default: "all"
  end
end

Again, what does :default mean here? Should the source be trusted to not populate a nil value on a field that has the :default option?

iex(1)> data = %{"category" => nil}
%{"category" => nil}

iex(2)> Ecto.embedded_load(MyApp.Posts.Post, data, :json)
%MyApp.Posts.Post{id: nil, category: nil}

We have no guarantees as the :default option only helps us with initialization:

iex(3) Ecto.embedded_dump(%MyApp.Posts.Post{}, :json)  
%{id: nil, category: "all"}

iex(4) Ecto.embedded_dump(%MyApp.Posts.Post{category: nil}, :json)
%{id: nil, category: nil}

It’s safer to transform the source data to ensure that fields don’t have a nil value if we depend on that:

iex(4) data = Map.update(data, "category", nil, & &1 || "all")
%{"category" => "all"}

iex(5) Ecto.embedded_load(MyApp.Posts.Post, data, :json)
%MyApp.Posts.Post{id: nil, category: "all"}

This is in the same vein as how we would enforce no nil value with a database constraint, though we have no control over storage here.

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process