Safe and Dangerous Schema Changes

Summary

When you change the model, schema sync updates the database for you on startup. Most changes are safe: sync applies them and nothing is lost. A few changes are dangerous: they can fail the upgrade or lose data, so you must split them over two releases.

Two ideas explain almost everything on this page:

  • Nothing is dropped. A removed column or table is only marked deleted. Name and data stay. If it comes back to the model, it is restored.
  • Two versions run at the same time. During a deploy, the old server version and the new one talk to the same database for a few minutes. A change is dangerous when the old version cannot live with it.

Quick table: what can go wrong

Change you make in the model Safe? What can go wrong
Add a column or table Yes Nothing. New column starts empty (or with the type default).
Remove a column or table Yes Nothing. It is marked deleted, data stays.
Re-add a removed column or table Yes Nothing. The mark is cleared and the object is restored.
Rename a property No The column name follows the property name. The old column is marked deleted, a new empty column is created. Data does not move.
Move data from one column to another in one release No The old server still writes the old column while the new server reads the new one. You lose the writes made during the deploy.
Add [Required] to an existing column No Upgrade fails at SET NOT NULL if any row is NULL.
Change or shrink a column type No PostgreSQL refuses casts it cannot do itself, or the data is truncated.
Add a required uuid / jsonb / bytea column No These types have no default value, so existing rows cannot be backfilled and SET NOT NULL fails.

Safe changes

How removal works

When a column or table disappears from the model, sync does not drop it and does not rename it. Instead it:

  • writes a benevia:deleted <timestamp> comment on the object (the deleted mark),
  • for a column: drops NOT NULL, and drops the constraints and indexes on that column,
  • for a table: drops the foreign keys that point to it. The table's own indexes are removed by the normal schema diff. Its primary key and check constraints stay.

The name and the data survive, so the old server version can still read and write the object while both versions run.

Example — you delete the Note property from Widget:

// Before
[Property<DataTypes.Text>("Note")]
public partial string Note { get; set; }

// After: the property is gone from the class

The "Note" column is still in the database, still full of data, and now has a benevia:deleted comment.

How restore works

If a marked object comes back to the model — you roll back a release, move between release channels, or simply add the property again — sync restores it instead of failing:

  • the deleted mark is removed,
  • NOT NULL is set again, and NULL rows are filled with the type default ('' for text, 0 for numbers, and so on). This backfill only runs when the column type did not change,
  • the indexes and foreign keys that were dropped at mark time are created again in the same run.

For uuid, jsonb and bytea there is no usable default, so no backfill runs. If NULL rows exist, SET NOT NULL fails with a clear PostgreSQL error and you must backfill the rows yourself.

Dangerous changes

Each of these needs an expand-contract recipe: release 1 adds the new thing and keeps the old one working, release 2 removes the old thing. Both server versions work against the same database at every step.

1. Moving data between columns

Problem: you replace Note with Comment in one release. During the deploy the old server still writes Note, but the new server reads Comment. Those writes are lost.

Recipe:

  1. Release 1 — add Comment. Copy the old values in a PropertyAdded subscriber, and write both columns from application code.

    [PropertyAdded(nameof(Widget), nameof(Widget.Comment))]
    public void CopyNoteToComment(PropertyAddedEventArgs args)
    {
        args.UpgradeScript("""UPDATE "Widget" SET "Comment" = "Note" WHERE "Comment" IS NULL;""");
    }
    
  2. Release 2 — remove Note from the model. It is marked deleted, so the data is still there if you need it.

2. Making an existing column required

Problem: you add [Required] to a column that has NULL rows. The upgrade fails at SET NOT NULL.

Recipe:

  1. Release 1 — fill the NULL rows and require the value in application validation, not yet in the model.

    namespace MyApp.Model;
    
    public class FeatureVersionDataUpgrade
    {
        [FeatureVersionChanged("MyApp.Model", 1)] // feature name must match the namespace
        public void FillEmptyCodes(FeatureVersionChangedEventArgs args)
        {
            args.UpgradeScript("""UPDATE "Widget" SET "Code" = 'UNKNOWN' WHERE "Code" IS NULL;""");
        }
    }
    
  2. Release 2 — add [Required] to the property. Now no row is NULL, so SET NOT NULL succeeds.

3. Changing or shrinking a column type

Problem: sync applies the type change directly. PostgreSQL rejects a cast it cannot do implicitly, and a smaller type can truncate or reject existing values.

Fix: write the conversion yourself with an explicit cast:

ALTER TABLE "Widget" ALTER COLUMN "LegacyCode" TYPE varchar(10) USING left("LegacyCode", 10);

If the type changed while the column was marked deleted, restore clears the mark first and the normal column-change operation applies the new type after that. If this leaves NULL rows in a required column, SET NOT NULL fails with a clear PostgreSQL error — backfill the rows and run the upgrade again.

4. Adding a required uuid / jsonb / bytea column

Problem: these types have no type default, so sync cannot fill existing rows and NOT NULL cannot be applied.

Fix: give the property an explicit default, or fill the rows in a PropertyAdded subscriber before NOT NULL is applied.

5. Renaming a property

Problem: the database column name comes from the C# property name, so renaming the property is a delete plus an add. The old column is marked deleted and a new, empty column is created. No data moves.

Fix: treat it as a rename in name only — you have to move the data yourself. Follow case 1: add the new property, copy the values in a PropertyAdded subscriber, write both properties for one release, then remove the old one in release 2.

Notes

  • The deleted mark overwrites any comment a person or a DBA put on the object, and restore sets the comment back to NULL. This is a deliberate tradeoff.
  • Objects that the old (pre-2026) behavior renamed to Deleted_{name}_at_{timestamp} are invisible to schema sync and are never restored automatically. Clean them up by hand if you need to.