View Source Update Actions

Update actions are used to update records in the data layer. For example:

# on a ticket resource
update :close do
  accept [:close_reason]
  change set_attribute(:status, :closed)

Here we have an update action called :close that allows setting the close_reason, and sets the status to :closed. It could be called like so:

ticket # providing an initial ticket to close
|> Ash.Changeset.for_update(:close, %{close_reason: "I figured it out."})
|> Ash.update!()

See the Code Interface guide for creating an interface to call the action more elegantly, like so:

Support.close_ticket!(ticket, "I figured it out.")
# You can also provide an id
Support.close_ticket!(, "I figured it out.")


Atomic updates can be added to a changeset, which will update the value of an attribute given by an expression. Atomics can be a very powerful way to model updating data in a simple way. An action does not have to be fully atomic in order to leverage atomic updates. For example:

update :add_to_name do
  argument :to_add, :string, allow_nil? false
  change atomic_update(:name, expr("#{name}_#{to_add}"))

Changing attributes in this way makes them safer to use in concurrent environments, and is typically more performant than doing it manually in memory.

Atomics are not stored with other changes

While we recommend using atomics wherever possible, it is important to note that they are stored in their own map in the changeset, i.e changeset.atomics, meaning if you need to do something later in the action with the new value for an attribute, you won't be able to access the new value. This is because atomics are evaluated in the data layer.

Fully Atomic updates

Atomic updates are a special case of update actions that can be done atomically. If your update action can't be done atomically, you will get an error unless you have set require_atomic? false. This is to encourage you to opt for atomic updates whereever reasonable. Not all actions can reasonably be made atomic, and not all non-atomic actions are problematic for concurrency. The goal is only to make sure that you are aware and have considered the implications.

What does atomic mean?

An atomic update is one that can be done in a single operation in the data layer. This ensures that there are no issues with concurrent access to the record being updated, and that it is as performant as possible. For example, the following action cannot be done atomically, because it has an anonymous function change on it.

update :increment_score do
  change fn changeset, _ ->
    Ash.Changeset.set_attribute(changeset, :score, + 1)

The action shown above is not safe to run concurrently. If two separate processes fetch the record with score 1, and then call increment_score, they will both set the score to 2, when what you almost certainly intended to do was end up with a score of 3

By contrast, the following action can be done atomically

update :increment_score do
  change atomic_update(:score, expr(score + 1)

In a SQL data layer, this would produce SQL along the lines of

"UPDATE table SET score = score + 1 WHERE id = post_id"

What makes an action not atomic?

Types that can't be atomically casted

Not all types support being casted atomically. For instance, :union types, and embedded resources that have primary keys(and therefore may need to use an update action) cannot currently be casted atomically.

Changes without an atomic callback

Changes can be enhanced to support atomics by defining Ash.Resource.Change.atomic/3. This callback can return a map of atomic updates to be made to attributes. Here is a simplified example from the built in Ash.Resource.Change.Builtins.increment/2 change:

@impl true
def atomic(_changeset, opts, _context) do
  # Set the requested attribute to its current value (atomic_ref) + the amount
  {:atomic, %{opts[:attribute] => expr(^atomic_ref(opts[:attribute]) + ^opts[:amount])}}

Validations without an atomic callback

Validations can be enhanced to support atomics by defining Ash.Resource.Validation.atomic/3. This callback can return an atomic validation (or a list of atomic validations), which is represented by a list of affected attributes (not currently used), an expression that should trigger an error, and the expression producing the error. Here is an example from the built in Ash.Resource.Validations.Builtins.attribute_equals/2 validation:

@impl true
def atomic(_changeset, opts, context) do
  {:atomic, [opts[:attribute]], expr(^atomic_ref(opts[:attribute]) != ^opts[:value]),
     error(^InvalidAttribute, %{
       field: ^opts[:attribute],
       value: ^atomic_ref(opts[:attribute]),
       message: ^(context.message || "must equal %{value}"),
       vars: %{field: ^opts[:attribute], value: ^opts[:value]}

Bulk updates

There are three strategies for bulk updating data. They are, in order of preference: :atomic, :atomic_batches, and :stream. When calling Ash.bulk_update/4, you can provide a strategy or strategies that can be used, and Ash will choose the best one available. The implementation of the update action and the capabilities of the data layer determine what strategies can be used.


Atomic bulk updates are used when the subject of the bulk update is a query, and the update action can be done atomically and the data layer supports updating a query. They map to a single statement to the data layer to update all matching records. The data layer must support updating a query.


|> Ash.Query.filter(status == :open)
|> Ash.bulk_update!(:close, %{reason: "Closing all open tickets."})

If using a SQL data layer, this would produce a query along the lines of

UPDATE tickets
SET status = 'closed',
    reason = 'Closing all open tickets.'
WHERE status = 'open';

Atomic Batches

Atomic batches is used when the subject of the bulk update is an enumerable (i.e list or stream) of records and the update action can be done atomically and the data layer supports updating a query. The records are pulled out in batches, and then each batch follows the logic described above. The batch size is controllable by the batch_size option.


Ash.bulk_update!(one_hundred_tickets, :close, %{reason: "Closing all open tickets."}, batch_size: 10)

If using a SQL data layer, this would produce ten queries along the lines of

UPDATE tickets
SET status = 'closed',
    reason = 'Closing all open tickets.'
WHERE id IN (...ids)


Stream is used when the update action cannot be done atomically or if the data layer does not support updating a query. If a query is given, it is run and the records are used as an enumerable of inputs. If an enumerable of inputs is given, each one is updated individually. There is nothing inherently wrong with doing this kind of update, but it will naturally be slower than the other two strategies. The benefit of having a single interface (Ash.bulk_update/4) is that the caller doesn't need to change based on the performance implications of the action.

Running a standard update action

All actions are run in a transaction if the data layer supports it. You can opt out of this behavior by supplying transaction?: false when creating the action. When an action is being run in a transaction, all steps inside of it are serialized because transactions cannot be split across processes.

  • Authorization is performed on the changes
  • A before action hook is added to set up belongs_to relationships that are managed. This means potentially creating/modifying the destination of the relationship, and then changing the destination_attribute of the relationship.
  • before_transaction and around_transaction hooks are called (Ash.Changeset.before_transaction/2). Keep in mind, any validations that are marked as before_action? true (or all global validations if your action has delay_global_validations? true) will not have happened at this point.
  • A transaction is opened if the action is configured for it (by default they are) and the data layer supports transactions
  • before_action hooks are performed in order
  • The main action is sent to the data layer
  • after_action hooks are performed in order
  • Non-belongs-to relationships are managed, creating/updating/destroying related records.
  • The transaction is closed, if one was opened
  • after_transaction hooks are invoked with the result of the transaction (even if it was an error)