Skip to content

Add ability to configure the behavior of writing to a non-writable field#4734

Merged
greg-rychlewski merged 6 commits into
elixir-ecto:masterfrom
green-david:dg-writable-violation-handling
Jun 16, 2026
Merged

Add ability to configure the behavior of writing to a non-writable field#4734
greg-rychlewski merged 6 commits into
elixir-ecto:masterfrom
green-david:dg-writable-violation-handling

Conversation

@green-david

Copy link
Copy Markdown
Contributor

This PR adds the ability to change the default behavior of silently ignoring changes to non-writable fields as discussed on the mailing list.

Attempting to write to a non-writable field can lead to code which appears to update the field but actually doesn't, leading to confusing behavior or hidden bugs. By allowing the user to specify that a warning should be logged or an exception raised, these errant writes can be detected and remedied.

Comment thread lib/ecto/repo/schema.ex Outdated
Comment on lines +641 to +650
message = "attempted to write to non-writable field #{inspect(name)} during #{action}"

case on_writable_violation do
:raise ->
raise ArgumentError, message
:warn ->
Logger.warning(message)
_ ->
:ok
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message here likely needs to be more robust and contain additional information, open to feedback. This is just a simple message for now to illustrate the functionality.

Comment thread lib/ecto/schema.ex
Comment on lines +704 to +709
* `:on_writable_violation` - Defines what action to take when performing an insert or update
attempts to modify a field that should not be modified according to it's `:writable` value.
Must be one of `:nothing`, `:warn`, or `:raise`. If set to `:nothing`, the modification is
silently ignored. If set to `:warn`, the modification is ignored and a warning is logged. If set
to `:raise`, an exception is raised and the operation is aborted. If `:writable` is set to `:always`,
`:on_writable_violation` must be set to `:nothing`. Defaults to `:nothing`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this as "the path of least resistance" to illustrate the functionality, but I am not confident this is the desired way forward as to the public facing API of how to configure this behavior.

Some other options:

writable: [when: :never, on_violation: :raise]
writable: {:never, :raise}
writable: [:never, :raise]

Open to suggestions :)

@josevalim

Copy link
Copy Markdown
Member

Looking at the code, I like the on_writable_violation approach. However, I wonder if the implementation should be module.__schema__(:on_writable_violation). It should be much simpler and we only need to invoke it if there is a violation indeed.

@josevalim

Copy link
Copy Markdown
Member

WDYT?

@green-david

Copy link
Copy Markdown
Contributor Author

Looking at the code, I like the on_writable_violation approach. However, I wonder if the implementation should be module.__schema__(:on_writable_violation). It should be much simpler and we only need to invoke it if there is a violation indeed.

Sounds good, I will make this change 👍

@green-david

Copy link
Copy Markdown
Contributor Author

@josevalim I pushed a commit simplifying things a bit by using the __schema__(:on_writable_violation) approach you suggested. Let me know if that is what you had in mind👍

Would also like your thoughts on the warning and exception. It feels like the messages could be more useful by including possibly the schema name? It also seems like the warning could benefit from a stacktrace, but im not sure of the best way to go about that or if that is overkill / a potential performance killer.

I also considered a structured Ecto.WritableViolationError instead of a generic ArgumentError, but wasn't sure if that was overkill as well. Seems like it could be nice.

Thank you for the feedback as always!

Comment thread lib/ecto/repo/schema.ex Outdated
Comment on lines +630 to +636
case Map.pop(changes, field) do
{nil, changes} ->
changes
{_change, changes} ->
handle_writable_violation(field, schema, action)
changes
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can accidentally ignore a field being written to nil. The best is probably:

case changes do
  %{^field => _} ->
    handle_writable_violation(field, schema, action)
    Map.delete(changes, field)
  %{} ->
    changes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦 Thank you for catching this! Will fix 👍

Comment thread lib/ecto/schema.ex Outdated
Comment on lines +2081 to +2084
if writable == :always and on_writable_violation != :nothing do
raise ArgumentError, "on_writable_violation must be :nothing for always writable fields"
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need this check. And maybe users want to set on_writable_violation: :warn as a default somewhere and this would get in the way. :)

Comment thread lib/ecto/schema.ex Outdated
end

Module.put_attribute(mod, :ecto_fields, {name, {type, writable}})
Module.put_attribute(mod, :ecto_fields, {name, {type, {writable, on_writable_violation}}})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not need to change this now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something - doesn't this need to be included here so that I can reference use the value to populate the data for the __schema__(:on_writable_violation) call below?

Or are you suggesting to separately populate something like :ecto_on_writable_violations and use that value in __schema__ instead perhaps?

I put it here so it was next to writable which was pre-existing 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, to use a separate attribute so we don't "pollute" all other fields to store this information.

@green-david

Copy link
Copy Markdown
Contributor Author

@josevalim Hopefully this is a bit closer! Thank you for the feedback 🙇

@josevalim josevalim left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greg-rychlewski, please merge if you see no issues :)

@greg-rychlewski

Copy link
Copy Markdown
Member

@green-david sorry do you mind rebasing from main? one of our docker images in the ci had an issue so the unit tests didn't even start. if you rebase it will let us make sure all the tests pass on that job

@green-david green-david force-pushed the dg-writable-violation-handling branch from bdb8c69 to fc4ae9d Compare June 16, 2026 14:15
@green-david

Copy link
Copy Markdown
Contributor Author

@greg-rychlewski No problem!

I noticed while I was rebasing that there was an issue where we were still silently ignoring surfaced changes even if configured otherwise which I pushed a fix and tests for in a new commit if you wouldn't mind taking a look.

I think there might still be a conflict between the default option and the on_writable_violation option as with a default value set we always have to surface that value and insert it at insertion time. Perhaps we should only let you configure on_writable_violation: :nothing if a default is set on the schema field to prevent you from shooting yourself in the foot? WDYT?

I also wonder if it would be desirable to have a Schema attribute like @on_writable_violation to be able to configure a global default similar to @schema_redact 🤔

@greg-rychlewski

Copy link
Copy Markdown
Member

I also wonder if it would be desirable to have a Schema attribute like @on_writable_violation to be able to configure a global default similar to @schema_redact

That's a good idea. I think it would be good for a follow up PR though because you basically got this one working and don't want to rock the boat.

I think there might still be a conflict between the default option and the on_writable_violation option as with a default value set we always have to surface that value and insert it at insertion time

I see what you are saying. I'm a bit conflicted because having violation: :nothing and something like writable: :update means you can use the same schema for inserts and updates and don't have to make two identical ones with the options changed. Maybe violation: :nothing with writable: :never is a good compromise because the default will never be used at all?

@greg-rychlewski

Copy link
Copy Markdown
Member

Or even just default with writable: :never should be forbidden because even with other violation behaviour you'll just get spammed with raises or warnings

@green-david

Copy link
Copy Markdown
Contributor Author

Or even just default with writable: :never should be forbidden because even with other violation behaviour you'll just get spammed with raises or warnings

Yeah, really the only use case I can think of for default with writable_never would perhaps be that you have a generated field in the database and for an in-memory built struct you want to default it to a reasonable value so it isn't nil, but then you don't want to actually write anything to the DB and let the generated column take over?

@greg-rychlewski

Copy link
Copy Markdown
Member

Maybe the best first step is to just leave it for now and see how users react to it and what reports we get. Because if people will start doing complicated things like you shared above then it's better to make less assumptions unless we see it's causing a lot of people problems.

@green-david

Copy link
Copy Markdown
Contributor Author

Maybe the best first step is to just leave it for now and see how users react to it and what reports we get. Because if people will start doing complicated things like you shared above then it's better to make less assumptions unless we see it's causing a lot of people problems.

Sounds good, thank you for thinking it through with me!!

@greg-rychlewski greg-rychlewski merged commit e1d4be8 into elixir-ecto:master Jun 16, 2026
8 checks passed
@greg-rychlewski

Copy link
Copy Markdown
Member

nice work and great idea :). if you wanted to send another PR for the schema attribute that would be appreciated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants