We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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, setnull: 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