Declarative Schema and Patches

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Almost every Magento extension that stores something needs a table, and every upgrade of a shop has to bring the tables of all its modules to the shape the new code expects. Until 2.3 this was done with PHP scripts that said how to get from one version to the next. Since then a module declares the shape it wants in an XML file and Magento works out the difference by itself, while one-off changes that cannot be declared (seed data, a view, a trigger) are classes called patches. This page covers both halves with the Pocket Coffee roast lot table: the db_schema.xml file, the whitelist that governs removals, the commands that preview and protect a change, data and schema patches, and, because shops and books still contain them, the legacy InstallSchema and UpgradeSchema scripts and the tool that converts them.

The page was written against Magento Open Source and Adobe Commerce 2.4.9 (generally available 2026-05-12) and Mage-OS 3.6.0 (2026-10-14, built on the 2.4.9 code base) and checked on 2026-10-27. Declarative schema and patches are part of the PHP platform, so they apply to Magento Open Source, Adobe Commerce on premises or on cloud infrastructure, and Mage-OS; Adobe Commerce as a Cloud Service (ACCS) accepts no PHP modules and so no schema of your own (see the editions page). Every command and output below comes from a Mage-OS 3.6.0 installation in developer mode (PHP 8.5.12, MariaDB 11.8.9, OpenSearch 3.9.0, Valkey 9.1.3). The module is PocketCoffee_Lot, whose service contract is on the previous page; three throw-away modules (PocketCoffee_LotLab, PocketCoffee_LotLegacy and PocketCoffee_LotRevert) were used for the experiments. The module skeleton and the enabling commands are on the module anatomy page.

How setup:upgrade changes the database

The decisive difference between the two generations is where the state lives. A legacy script compares the installed module version with the version it is written for and runs only the steps in between, so the sequence of scripts is the history of the module. The declarative file describes the end state; Magento reads the database, computes the differences and executes atomic statements. Patches are the exception: they run once, are recorded, and are never repeated. bin/magento setup:upgrade (also run by setup:install) does the following, in this order, which can be read in the installer of the 2.4.9 code:

  1. Declarative schema. The db_schema.xml files of all enabled modules are merged, compared with the database and applied.

  2. Legacy schema scripts of every module that still declares setup_version in module.xml: InstallSchema and UpgradeSchema.

  3. Schema patches of the module, then recurring schema scripts of all modules.

  4. Legacy data scripts (InstallData, UpgradeData) and data patches, module by module, then recurring data scripts.

The seven steps of setup:upgrade from declarative schema to recurring data scripts with the database tables that remember what ran

Because the declarative step is first, a patch may rely on the tables and columns that the same release declares. Because the patches come after the legacy scripts, a module that is being migrated can keep both for a while (see the last section).

The next sections follow the lifecycle of one table, pocketcoffee_lot, from declaration to removal.

db_schema.xml

The file is etc/db_schema.xml in the module. Its root element is schema, validated by urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd, and it contains table elements, each with column, constraint and index children. Several modules may declare the same table; the files are merged, which is how one module adds a column to the table of another.

The table of the roast lots

The complete declaration, with a column of each kind that Pocket Coffee needs: an auto-increment key, a foreign key to the product, a unique pair, a date, a decimal, a status with a default, and two timestamps that the database maintains.

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="pocketcoffee_lot" resource="default" engine="innodb" comment="Pocket Coffee roast lots">
        <column xsi:type="int" name="lot_id" unsigned="true" nullable="false" identity="true" comment="Lot ID"/>
        <column xsi:type="int" name="product_id" unsigned="true" nullable="false" comment="Product ID"/>
        <column xsi:type="varchar" name="sku" nullable="false" length="64" comment="SKU of the product"/>
        <column xsi:type="varchar" name="origin" nullable="false" length="32" comment="Coffee origin"/>
        <column xsi:type="date" name="roasted_at" nullable="false" comment="Roast date"/>
        <column xsi:type="decimal" name="weight_kg" scale="3" precision="8" unsigned="true" nullable="false" default="0" comment="Weight roasted in kilograms"/>
        <column xsi:type="smallint" name="status" unsigned="true" nullable="false" default="0" comment="0 roasting, 1 ready, 2 sold out"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="false" comment="Created at"/>
        <column xsi:type="timestamp" name="updated_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="true" comment="Updated at"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="lot_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="POCKETCOFFEE_LOT_PRODUCT_ID_CATALOG_PRODUCT_ENTITY_ENTITY_ID" table="pocketcoffee_lot" column="product_id" referenceTable="catalog_product_entity" referenceColumn="entity_id" onDelete="CASCADE"/>
        <constraint xsi:type="unique" referenceId="POCKETCOFFEE_LOT_SKU_ROASTED_AT">
            <column name="sku"/>
            <column name="roasted_at"/>
        </constraint>
        <index referenceId="POCKETCOFFEE_LOT_ORIGIN" indexType="btree">
            <column name="origin"/>
        </index>
        <index referenceId="POCKETCOFFEE_LOT_STATUS_ROASTED_AT" indexType="btree">
            <column name="status"/>
            <column name="roasted_at"/>
        </index>
    </table>
</schema>

The module needs one more file, etc/db_schema_whitelist.json, described below. With both in place, setup:db:status before the first upgrade reports what is missing, in a machine-readable form:

$ bin/magento setup:db:status
Validator Magento\Framework\Setup\Declaration\Schema\UpToDateDeclarativeSchema isUpToDate: false
Declarative Schema is not up to date
Details: {
    "total_differences": 1,
    "by_change_type": {
        "create_table": 1
    },
    "affected_tables": {
        "pocketcoffee_lot": {
            "create_table": 1
        }
    },
    "changes": [
        {
            "operation": "create_table",
            "index": 0,
            "table": "pocketcoffee_lot",
            "name": "pocketcoffee_lot",
            "type": "table"
        }
    ]
}
Validator Magento\Framework\Setup\Patch\UpToDateSchema isUpToDate: true
Validator Magento\Framework\Setup\Patch\UpToDateData isUpToDate: true
Validator Magento\Framework\Setup\OldDbValidator isUpToDate: true
Run 'setup:upgrade' to update your DB schema and data.

setup:upgrade --dry-run=1 does not change the database and writes the statements it would run to var/log/dry-run-installation.log:

$ bin/magento setup:upgrade --dry-run=1
$ cat var/log/dry-run-installation.log
CREATE TABLE `pocketcoffee_lot` (
`lot_id` int UNSIGNED NOT NULL  AUTO_INCREMENT COMMENT "Lot ID",
`product_id` int UNSIGNED NOT NULL   COMMENT "Product ID",
`sku` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL  COMMENT "SKU of the product",
`origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL  COMMENT "Coffee origin",
`roasted_at` date NOT NULL COMMENT "Roast date",
`weight_kg` decimal(8, 3) UNSIGNED NOT NULL DEFAULT 0 COMMENT "Weight roasted in kilograms",
`status` smallint UNSIGNED NOT NULL DEFAULT 0  COMMENT "0 roasting, 1 ready, 2 sold out",
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP  COMMENT "Created at",
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT "Updated at",
CONSTRAINT  PRIMARY KEY (`lot_id`),
CONSTRAINT `POCKETCOFFEE_LOT_PRODUCT_ID_CATALOG_PRODUCT_ENTITY_ENTITY_ID` FOREIGN KEY (`product_id`) REFERENCES `catalog_product_entity` (`entity_id`)  ON DELETE CASCADE,
CONSTRAINT `POCKETCOFFEE_LOT_SKU_ROASTED_AT` UNIQUE KEY (`sku`,`roasted_at`),
INDEX `POCKETCOFFEE_LOT_ORIGIN` (`origin`),
INDEX `POCKETCOFFEE_LOT_STATUS_ROASTED_AT` (`status`,`roasted_at`)
) ENGINE=innodb DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_general_ci COMMENT="Pocket Coffee roast lots"

After the real upgrade the table exists as declared. Two details are worth reading: the key names are the upper case names that Magento generates from the table and the columns, and the foreign key got an index of its own, because MariaDB requires one.

$ mariadb magento -e "show create table pocketcoffee_lot\G"
CREATE TABLE `pocketcoffee_lot` (
  `lot_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Lot ID',
  `product_id` int(10) unsigned NOT NULL COMMENT 'Product ID',
  `sku` varchar(64) NOT NULL COMMENT 'SKU of the product',
  `origin` varchar(32) NOT NULL COMMENT 'Coffee origin',
  `roasted_at` date NOT NULL COMMENT 'Roast date',
  `weight_kg` decimal(8,3) unsigned NOT NULL DEFAULT 0.000 COMMENT 'Weight roasted in kilograms',
  `status` smallint(5) unsigned NOT NULL DEFAULT 0 COMMENT '0 roasting, 1 ready, 2 sold out',
  `created_at` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'Created at',
  `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT 'Updated at',
  PRIMARY KEY (`lot_id`),
  UNIQUE KEY `POCKETCOFFEE_LOT_SKU_ROASTED_AT` (`sku`,`roasted_at`),
  KEY `POCKETCOFFEE_LOT_PRODUCT_ID_CATALOG_PRODUCT_ENTITY_ENTITY_ID` (`product_id`),
  KEY `POCKETCOFFEE_LOT_ORIGIN` (`origin`),
  KEY `POCKETCOFFEE_LOT_STATUS_ROASTED_AT` (`status`,`roasted_at`),
  CONSTRAINT `POCKETCOFFEE_LOT_PRODUCT_ID_CATALOG_PRODUCT_ENTITY_ENTITY_ID` FOREIGN KEY (`product_id`) REFERENCES `catalog_product_entity` (`entity_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Pocket Coffee roast lots'
erDiagram catalog_product_entity ||--o{ pocketcoffee_lot : "product_id, ON DELETE CASCADE" pocketcoffee_lot { int lot_id PK int product_id FK varchar sku varchar origin date roasted_at decimal weight_kg smallint status timestamp created_at timestamp updated_at }

The diagram shows the table and the foreign key to the catalog. A summary view over the table is added later by a schema patch, because db_schema.xml cannot declare views.

Table attributes

Attribute Values Meaning

name

string, required

Table name. The table prefix of the installation (the table_prefix key of the db section of env.php) is added automatically.

resource

default, checkout, sales

The database connection (shard) the table is created on. The values checkout and sales matter only for installations that split the database.

engine

innodb, memory

Storage engine.

comment

string

Table comment, shown in SHOW CREATE TABLE.

charset, collation

strings

Override of the default (the page’s table used the defaults utf8mb4 and utf8mb4_general_ci).

onCreate

migrateDataFromAnotherTable(name)

Copies the rows of an existing table when this table is created, used to rename a table.

disabled

true

Drops the table (the same attribute exists on every child element).

Column types and attributes

The xsi:type of a column selects the SQL type and the attributes that the XSD allows for it. The types of the 2.4.9 schema are smallint, tinyint, int and bigint (the integer family), decimal, float and double, varchar and char, text, mediumtext and longtext, json, boolean, date, datetime and timestamp, and blob, mediumblob, longblob and varbinary.

Attribute Applies to and meaning

name, comment

All columns. The name is required; the comment ends up in the database.

nullable

true or false. Omitted, the column is nullable.

default

A value of the column’s own type. For timestamp it is CURRENT_TIMESTAMP or a literal.

unsigned

Integers and decimal.

identity

Integers: true makes the column AUTO_INCREMENT.

padding

Integers: the display width. The converter of legacy scripts writes it, new declarations leave it out.

length

varchar, char and varbinary.

precision, scale

decimal and float: digits in total and after the point (the lot weight is precision 8 and scale 3).

on_update

timestamp: true adds ON UPDATE CURRENT_TIMESTAMP, as on updated_at.

onCreate

migrateDataFrom(column): copies a column when this one is created, used to rename.

disabled

true drops the column.

A column without nullable is nullable, which matters when a declaration is converted from a database dump or from legacy code: the safe habit is to write nullable="false" explicitly, as the table above does. A decimal column prints its default as 0.000 in the database even though the XML says 0.

Constraints and indexes

Constraints and indexes are declared with a referenceId, which is only the identifier that ties the declaration to the entries of the whitelist and to declarations of other modules. The name the database gets is generated: in an experiment, an index with the referenceId MY_CUSTOM_NOTE_ID on the column note of the table pocketcoffee_revert became the key POCKETCOFFEE_REVERT_NOTE, and that generated name is what the whitelist stores. Adobe recommends copying the generated names into the referenceId, which is what the Pocket Coffee file does, so that the declaration and the whitelist agree.

Element Notes

constraint xsi:type="primary"

One per table. referenceId is PRIMARY. Contains one column element per key column.

constraint xsi:type="unique"

Unique key over the listed columns (sku and roasted_at above).

constraint xsi:type="foreign"

Attributes table, column, referenceTable, referenceColumn and onDelete, which is CASCADE, SET NULL or NO ACTION. There is no onUpdate: identifiers are meant to be immutable. Both tables must be declared declaratively.

index

Attribute indexType: btree (the default), fulltext or hash. Contains the column elements; a fulltext index on a text column was created in an experiment as FULLTEXT INDEX.

The whitelist

Declarative schema is eager to create and change, and deliberately careful about removing. A table, column, index or constraint that disappears from the XML is dropped only when the element is listed in the module’s etc/db_schema_whitelist.json. The file is a history of the elements the module has declared; Magento generates it from the current db_schema.xml:

bin/magento setup:db-declaration:generate-whitelist --module-name=PocketCoffee_Lot
{
    "pocketcoffee_lot": {
        "column": {
            "lot_id": true,
            "product_id": true,
            "sku": true,
            "origin": true,
            "roasted_at": true,
            "weight_kg": true,
            "status": true,
            "created_at": true,
            "updated_at": true
        },
        "index": {
            "POCKETCOFFEE_LOT_ORIGIN": true,
            "POCKETCOFFEE_LOT_STATUS_ROASTED_AT": true
        },
        "constraint": {
            "PRIMARY": true,
            "POCKETCOFFEE_LOT_PRODUCT_ID_CATALOG_PRODUCT_ENTITY_ENTITY_ID": true,
            "POCKETCOFFEE_LOT_SKU_ROASTED_AT": true
        }
    }
}

Without --module-name the command generates the whitelist of every module, and the default is all. The file has to be generated (and committed) in every release that changes db_schema.xml. It cannot be generated on an installation that uses a database prefix, because the prefix changes some of the names that the file stores; generate it on a development installation without one. The documentation calls the file temporary; in 2.4.9 it is still required, and setup:db-declaration:generate-whitelist is part of bin/magento list on Mage-OS 3.6.0.

What the whitelist does and does not gate

The behaviour was measured with the experimental module PocketCoffee_LotLab, a table pocketcoffee_lab with a title column and a grind column, changed step by step. Each step used setup:upgrade --dry-run=1 to read the statements and then the real upgrade.

Change in db_schema.xml Whitelist Result

Add the column size_g and widen title from 64 to 128 characters

none yet

Applied: ALTER TABLE ... MODIFY COLUMN title varchar(128) ..., ADD COLUMN size_g smallint ....

Remove the column grind from the XML

none yet

Not dropped. The dry run printed no statement and SHOW CREATE TABLE still had the column.

Generate the whitelist now (the column is already gone from the XML)

lab_id, title, size_g

Still not dropped: the whitelist is generated from the current XML, so it never learned about grind.

Put grind back, generate the whitelist, then remove the column again

lists grind

ALTER TABLE pocketcoffee_lab DROP COLUMN grind was executed.

Replace title with name (onCreate="migrateDataFrom(title)"), regenerate the whitelist

lists title and name

ADD COLUMN name, data copied, DROP COLUMN title. The new column lands at the end of the table.

Disable the module (and run setup:upgrade)

lists the table

DROP TABLE pocketcoffee_lab.

The lessons, in order of importance. Adding and changing need no whitelist; removing does. The whitelist must be generated while the element is still in the XML (add, generate, release; later remove), or Magento will keep the orphan forever. Disabling a module drops its whitelisted tables on the next setup:upgrade, a behaviour that the documentation describes ("the db_schema_whitelist.json file of disabled modules is still read") and that the last row confirmed on the local shop. A module that has no whitelist keeps its tables when it is disabled (a one-table module without the file was enabled, upgraded, disabled and upgraded again, and the table stayed).

Protecting data during a change

Removing a column or a table destroys data, and the installer has two options to soften that. --safe-mode=1 writes a CSV dump of what a destructive change would lose; --data-restore=1 puts it back into the column or table when the declaration returns. In the lab, removing grind with setup:upgrade --safe-mode=1 produced this file, and re-adding the column with setup:upgrade --data-restore=1 brought the value back and deleted the dump:

$ cat var/declarative_dumps_csv/pocketcoffee_lab_column_grind.csv
lab_id,grind
1,espresso

$ bin/magento setup:upgrade --data-restore=1
$ mariadb magento -e "select * from pocketcoffee_lab"
lab_id	name	size_g	grind
1	Single-origin beans	espresso	250

The file name has the form <table>_column_<column>.csv, which differs from the pattern in the documentation text ({column_name_column_type_other_dimensions}.csv); the documentation gives <table>.csv for a dropped table. Destructive operations, in the documentation’s list, are deleting a table or a column, reducing a column length, changing the precision, and changing the column type. Before such a release, take a database backup anyway: the dump covers only what the installer recognizes.

Evolving a table

All the operations below are changes of the XML followed by setup:upgrade; only some need the whitelist. The rows marked "run" were executed on the local shop; the others are in the documentation.

Operation How Whitelist

Create a table

Add the table element, generate the whitelist, upgrade. Run on pocketcoffee_lot.

not needed

Add a column or an index

Add the element. Run.

not needed

Change a column type or length

Change the attributes of the column. Run (varchar(64) to varchar(128)).

not needed

Drop a column or an index

Delete the element. Run for a column; the documentation says the same for constraints and indexes.

required

Rename a column

Declare the new column with onCreate="migrateDataFrom(old)" and delete the old one. Run. Not supported in the same step as the migration of data from another table.

old name required

Rename a table

Declare the new table with onCreate="migrateDataFromAnotherTable(old)" and delete the old one. Run (pocketcoffee_revert to pocketcoffee_revert_v2, rows kept, old table dropped). The statement RENAME TABLE is not used, and large tables are copied row by row.

old name required

Drop a table

Delete the table element. Keep the entry in the whitelist or the table is never dropped.

required

Drop a column or constraint declared by another module

Redeclare it with disabled="true" in a module that loads later (sequence). Run: a DROP COLUMN was planned and executed for a column declared in PocketCoffee_LotLegacy.

of the declaring module

Change a primary key declared elsewhere

Disable the old key (referenceId PRIMARY with disabled="true") and declare a new one with another referenceId; the name in the database stays PRIMARY.

required

For the cross-module row, the whitelist of the module that declared the column (here weight_kg in PocketCoffee_LotLegacy) was what allowed the drop. Foreign keys can only be added between tables that both come from declarative schema.

Patches

Not everything is a table shape. Seed data, a view, a trigger, the creation of an EAV attribute or the move of rows from one column to another are one-off changes, and they are written as patches: classes that Magento finds by their folder, runs once and records. There are two kinds.

Data patch Schema patch

Folder

Setup/Patch/Data/

Setup/Patch/Schema/

Interface

Magento\Framework\Setup\Patch\DataPatchInterface

Magento\Framework\Setup\Patch\SchemaPatchInterface

For

DML: inserts and updates, EAV attributes and configuration through the setup classes.

DDL that the declarative file cannot express: views, triggers, stored procedures, renames, partitions, table options.

Runs

After the declarative schema and the schema patches of its module.

After the declarative schema and the legacy schema scripts of its module.

Transaction

Inside one, rolled back on an exception.

None: DDL commits implicitly in MySQL and MariaDB.

Both interfaces extend PatchInterface, which demands three methods: apply(), which does the work and returns $this, the static getDependencies(), which lists the patch classes that must run first, and getAliases(), which lists the former class names of the patch. The applied patches are stored by class name in the table patch_list; after the Pocket Coffee module was installed it held three rows for it:

$ mariadb magento -e "select patch_id, patch_name from patch_list where patch_name like '%PocketCoffee%Lot%'"
patch_id	patch_name
201	PocketCoffee\\Lot\\Setup\\Patch\\Schema\\AddLotSummaryView
202	PocketCoffee\\Lot\\Setup\\Patch\\Data\\AddFirstLots
203	PocketCoffee\\Lot\\Setup\\Patch\\Data\\MarkOldLotsSoldOut

Because the identity of a patch is its class name, a patch must not be renamed or moved without an alias, and a patch that has been applied can never be changed in place: write a new patch.

Generating a stub

The command setup:db-declaration:generate-patch writes the skeleton of a patch into the right folder, with the options --revertable (adds PatchRevertableInterface) and --type=data|schema:

$ bin/magento setup:db-declaration:generate-patch --revertable=true PocketCoffee_Lot AddFirstLots
Patch 10as been successfully generated.

The garbled "10as" is the actual message of 2.4.9 (a formatting slip for "has"); the file is correct. The stub, before editing:

<?php
/**
 * Copyright 2018 Adobe
 * All Rights Reserved.
 */
declare(strict_types=1);

namespace PocketCoffee\Lot\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\Patch\PatchRevertableInterface;

/**
* Patch is mechanism, that allows to do atomic upgrade data changes
*/
class AddFirstLots implements
    DataPatchInterface,
    PatchRevertableInterface
{
    /**
     * @var ModuleDataSetupInterface $moduleDataSetup
     */
    private $moduleDataSetup;

    /**
     * @param ModuleDataSetupInterface $moduleDataSetup
     */
    public function __construct(ModuleDataSetupInterface $moduleDataSetup)
    {
        $this->moduleDataSetup = $moduleDataSetup;
    }

    /**
     * Do Upgrade
     *
     * @return void
     */
    public function apply()
    {
    }

    /**
     * @inheritdoc
     */
    public function revert()
    {
    }

    /**
     * @inheritdoc
     */
    public function getAliases()
    {
        return [];
    }

    /**
     * @inheritdoc
     */
    public static function getDependencies()
    {
        return [];
    }
}

A data patch

The first Pocket Coffee patch seeds the lots of the product POCKET-001 and skips silently when the product does not exist (so that the module installs in a shop without the demo product). It uses the setup object that the container injects, ModuleDataSetupInterface, with startSetup() and endSetup() around the work: the first sets the SQL mode NO_AUTO_VALUE_ON_ZERO and turns the foreign key checks off, the second restores both.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

/**
 * Seeds three roast lots for the product POCKET-001 (skipped when the product does not exist). It inserts rows, so it
 * is not revertable: reverting an insert needs a DELETE, which the revert contract forbids.
 */
class AddFirstLots implements DataPatchInterface
{
    private const SKU = 'POCKET-001';

    private const LOTS = [
        ['origin' => 'Ethiopia', 'roasted_at' => '2026-09-14', 'weight_kg' => '12.500', 'status' => 1],
        ['origin' => 'Colombia', 'roasted_at' => '2026-10-05', 'weight_kg' => '20.000', 'status' => 1],
        ['origin' => 'Brazil', 'roasted_at' => '2026-10-26', 'weight_kg' => '15.250', 'status' => 0],
    ];

    public function __construct(private readonly ModuleDataSetupInterface $moduleDataSetup)
    {
    }

    /**
     * @inheritDoc
     */
    public function apply()
    {
        $connection = $this->moduleDataSetup->getConnection();
        $connection->startSetup();

        $productId = $connection->fetchOne(
            $connection->select()
                ->from($this->moduleDataSetup->getTable('catalog_product_entity'), 'entity_id')
                ->where('sku = ?', self::SKU)
        );
        if ($productId) {
            $rows = [];
            foreach (self::LOTS as $lot) {
                $rows[] = ['product_id' => (int) $productId, 'sku' => self::SKU] + $lot;
            }
            $connection->insertOnDuplicate(
                $this->moduleDataSetup->getTable('pocketcoffee_lot'),
                $rows,
                ['weight_kg', 'status']
            );
        }

        $connection->endSetup();
        return $this;
    }

    /**
     * @inheritDoc
     */
    public function getAliases()
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public static function getDependencies()
    {
        return [];
    }
}

Patches run in the transaction of the setup connection. To prove it, a patch of the experimental module inserted a row and then threw an exception. The upgrade failed with the exception message, the row was not in the table, and no row of the patch was added to patch_list, so the next upgrade tries again:

$ bin/magento setup:upgrade
...
Unable to apply data patch PocketCoffee\LotRevert\Setup\Patch\Data\FailingPatch for module PocketCoffee_LotRevert. Original exception message: Roaster is on fire
Upgrade failed: Unable to apply data patch ...

A patch that has to commit in the middle (a large migration, or DDL) marks itself with Magento\Framework\Setup\Patch\NonTransactionableInterface and takes responsibility for its own consistency. Schema patches never run in a transaction and a failure is wrapped in the exception Unable to apply patch ... for module ....

Dependencies

getDependencies() returns the class names of patches, in any module, that must have run before. The second patch of the module marks the old lots as sold out and depends on the first:

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\Framework\Setup\Patch\PatchRevertableInterface;
use PocketCoffee\Lot\Api\Data\LotInterface;

/**
 * Marks the lots roasted before 2026-09-30 as sold out. It must run after AddFirstLots, and it only uses UPDATE, which
 * is what the revert contract allows: revert() puts the same lots back to ready.
 */
class MarkOldLotsSoldOut implements DataPatchInterface, PatchRevertableInterface
{
    private const CUTOFF = '2026-09-30';

    public function __construct(private readonly ModuleDataSetupInterface $moduleDataSetup)
    {
    }

    /**
     * @inheritDoc
     */
    public function apply()
    {
        $connection = $this->moduleDataSetup->getConnection();
        $connection->startSetup();
        $connection->update(
            $this->moduleDataSetup->getTable('pocketcoffee_lot'),
            ['status' => LotInterface::STATUS_SOLD_OUT],
            ['roasted_at < ?' => self::CUTOFF, 'status = ?' => LotInterface::STATUS_READY]
        );
        $connection->endSetup();
        return $this;
    }

    /**
     * @inheritDoc
     */
    public function revert()
    {
        $connection = $this->moduleDataSetup->getConnection();
        $connection->startSetup();
        $connection->update(
            $this->moduleDataSetup->getTable('pocketcoffee_lot'),
            ['status' => LotInterface::STATUS_READY],
            ['roasted_at < ?' => self::CUTOFF, 'status = ?' => LotInterface::STATUS_SOLD_OUT]
        );
        $connection->endSetup();
    }

    /**
     * @inheritDoc
     */
    public function getAliases()
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public static function getDependencies()
    {
        return [AddFirstLots::class];
    }
}

A patch with no dependencies can run in any order relative to the others. A cycle is caught before anything runs: two experimental patches that listed each other stopped the upgrade with a clear message.

$ bin/magento setup:upgrade
...
Cyclomatic dependency during patch installation
Upgrade failed: Cyclomatic dependency during patch installation

A patch can depend on a patch of another module; that module must then be enabled, and should be in sequence.

Aliases: renaming a patch

getAliases() lets a patch change its name without running again on shops that applied the old name. The experiment renamed AddNote to AddNoteRenamed and listed the old class as an alias. The new patch inserts into a table that no longer exists, so any actual run would have failed, yet the upgrade completed, and patch_list now held both names:

<?php
declare(strict_types=1);

namespace PocketCoffee\LotRevert\Setup\Patch\Data;

use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;

/**
 * Former name: AddNote. The alias keeps databases that applied the old name from running the patch again.
 */
class AddNoteRenamed implements DataPatchInterface
{
    public function __construct(private readonly ModuleDataSetupInterface $setup)
    {
    }

    public function apply()
    {
        // inserts into a table that no longer exists: it fails if the patch runs again
        $this->setup->getConnection()->insert($this->setup->getTable('pocketcoffee_revert'), ['note' => 'applied']);
        return $this;
    }

    public function getAliases()
    {
        return [AddNote::class];
    }

    public static function getDependencies()
    {
        return [];
    }
}
$ mariadb magento -e "select patch_name from patch_list where patch_name like '%LotRevert%'"
PocketCoffee\\LotRevert\\Setup\\Patch\\Data\\AddNote
PocketCoffee\\LotRevert\\Setup\\Patch\\Data\\AddNoteRenamed

A schema patch

Views are the standard example of what the declarative file cannot say. The patch below creates a summary view of the lots by origin with CREATE OR REPLACE VIEW through the connection of SchemaSetupInterface. The declarative schema ignores objects it does not know: setup:db:status stayed green with the view in place.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Setup\Patch\Schema;

use Magento\Framework\Setup\Patch\SchemaPatchInterface;
use Magento\Framework\Setup\SchemaSetupInterface;

/**
 * Creates a MySQL view over pocketcoffee_lot. db_schema.xml cannot declare views, so this is a schema patch.
 * Schema patches are never reverted by the Framework, so the view stays when the module is uninstalled.
 */
class AddLotSummaryView implements SchemaPatchInterface
{
    private const VIEW = 'pocketcoffee_lot_summary';

    public function __construct(private readonly SchemaSetupInterface $schemaSetup)
    {
    }

    /**
     * @inheritDoc
     */
    public function apply()
    {
        $this->schemaSetup->startSetup();
        $view = $this->schemaSetup->getTable(self::VIEW);
        $lots = $this->schemaSetup->getTable('pocketcoffee_lot');
        $this->schemaSetup->getConnection()->query(
            "CREATE OR REPLACE VIEW {$view} AS "
            . "SELECT origin, COUNT(*) AS lots, SUM(weight_kg) AS total_kg, MAX(roasted_at) AS last_roasted_at "
            . "FROM {$lots} GROUP BY origin"
        );
        $this->schemaSetup->endSetup();
        return $this;
    }

    /**
     * @inheritDoc
     */
    public function getAliases()
    {
        return [];
    }

    /**
     * @inheritDoc
     */
    public static function getDependencies()
    {
        return [];
    }
}
$ mariadb magento -e "select * from pocketcoffee_lot_summary"
origin	lots	total_kg	last_roasted_at
Brazil	1	15.250	2026-10-26
Colombia	1	20.000	2026-10-05
Ethiopia	1	12.500	2026-09-14

The Magento2 coding standard reports the query() call as a "possible raw SQL statement" warning; it is accepted here because the statement is DDL on names that come from the module itself and from the table prefix aware getTable().

flowchart TD A["Change needed in the database"] --> B{"Can it be said as a table, column, key or index?"} B -- yes --> C["db_schema.xml, and the whitelist for removals"] B -- no --> D{"Is it DDL such as a view, trigger or rename?"} D -- yes --> E["Schema patch, never reverted"] D -- no --> F{"Is it data: rows, EAV attribute, configuration?"} F -- yes --> G["Data patch, in a transaction"] F -- no --> H["Reconsider: code that must run on every upgrade is a recurring script"] G --> I{"Can the change be undone with INSERT or UPDATE only?"} I -- yes --> J["Add PatchRevertableInterface"] I -- no --> K["Leave it non-revertable"]

Reverting patches

A data patch can implement Magento\Framework\Setup\Patch\PatchRevertableInterface, whose single method revert() undoes the changes of apply(). The interface comment states the contract: no application layer (serializers, collections), only the DML operations INSERT and UPDATE, DELETE is prohibited because it can trigger foreign keys, and "all schema patches are not revertable". The 2.4.9 code agrees: the only caller is the module uninstaller, and the only method of the applier that reverts is revertDataPatches(); there is none for schema patches, so the revert() of a schema patch is never called.

A revert is never applied to one patch: it is applied to all revertable data patches of a module, in reverse order of the dependency graph, by the uninstall command. For a module in app/code:

$ bin/magento module:uninstall --non-composer --remove-data -n PocketCoffee_Lot
$ mariadb magento -e "select lot_id, origin, status from pocketcoffee_lot"
lot_id  origin    status
1       Ethiopia  1            <- was 2 before; MarkOldLotsSoldOut::revert() ran
...
$ mariadb magento -e "select patch_name from patch_list where patch_name like '%Lot%Patch%'"
PocketCoffee\\Lot\\Setup\\Patch\\Schema\\AddLotSummaryView
PocketCoffee\\Lot\\Setup\\Patch\\Data\\AddFirstLots

What the run showed. The revertable patch ran its revert(), set the Ethiopia lot back to ready and its row left patch_list. The non-revertable AddFirstLots was left alone, its rows stayed, the schema patch row stayed and the view stayed. The table was not dropped and the module stayed enabled: the command removes the data of revertable patches, nothing else. The next setup:upgrade applied the patch again (the lot returned to status 2), which is also the answer to "can I re-run a patch": remove its row from patch_list, or revert it, and upgrade. The documentation adds the Composer variant, bin/magento module:uninstall Vendor_Module, for modules installed with Composer.

Data patches for attributes

EAV attributes (products, customers, categories) are created by data patches that use the setup factories, for example EavSetupFactory, injected into the patch and used with the ModuleDataSetupInterface. The documentation’s troubleshooting note warns to inject the factory and not EavSetup itself, or setup:upgrade fails; the next page of the learning path on EAV and attributes has the complete examples.

Legacy scripts and the migration to declarative schema

Shops and 2016 books are full of InstallSchema, UpgradeSchema, InstallData, UpgradeData and the Recurring scripts of modules written for 2.0 to 2.2. They are the reason the Magento 2 Cookbook has a recipe called "Creating tables using setup scripts". They still run in 2.4.9, but only if module.xml declares setup_version; without it the installer skips the legacy scripts of that module and runs patches only. A legacy table of Pocket Coffee, as the books write it:

<?php
declare(strict_types=1);

namespace PocketCoffee\LotLegacy\Setup;

use Magento\Framework\DB\Ddl\Table;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;

/**
 * Legacy style (before 2.3): runs once, when the module is installed.
 */
class InstallSchema implements InstallSchemaInterface
{
    /**
     * @inheritDoc
     */
    public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
    {
        $setup->startSetup();
        $table = $setup->getConnection()
            ->newTable($setup->getTable('pocketcoffee_legacy_lot'))
            ->addColumn('lot_id', Table::TYPE_INTEGER, null, ['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true], 'Lot ID')
            ->addColumn('sku', Table::TYPE_TEXT, 64, ['nullable' => false], 'SKU')
            ->addColumn('roasted_at', Table::TYPE_DATE, null, ['nullable' => false], 'Roast date')
            ->addIndex($setup->getIdxName('pocketcoffee_legacy_lot', ['sku']), ['sku'])
            ->setComment('Legacy roast lots');
        $setup->getConnection()->createTable($table);
        $setup->endSetup();
    }
}
<?php
declare(strict_types=1);

namespace PocketCoffee\LotLegacy\Setup;

use Magento\Framework\DB\Ddl\Table;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
use Magento\Framework\Setup\UpgradeSchemaInterface;

/**
 * Legacy style: every upgrade checks the installed version by hand.
 */
class UpgradeSchema implements UpgradeSchemaInterface
{
    /**
     * @inheritDoc
     */
    public function upgrade(SchemaSetupInterface $setup, ModuleContextInterface $context)
    {
        $setup->startSetup();
        if (version_compare($context->getVersion(), '1.0.1', '<')) {
            $setup->getConnection()->addColumn(
                $setup->getTable('pocketcoffee_legacy_lot'),
                'weight_kg',
                ['type' => Table::TYPE_DECIMAL, 'length' => '8,3', 'nullable' => false, 'default' => '0.000', 'comment' => 'Weight in kg']
            );
        }
        $setup->endSetup();
    }
}
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="PocketCoffee_LotLegacy" setup_version="1.0.1"/>
</config>

The module installed on Mage-OS 3.6.0 (setup_module recorded schema_version and data_version 1.0.1) and the Magento2 coding standard answered with two errors, one for each script: "InstallSchema scripts are obsolete. Please use declarative schema approach in module’s etc/db_schema.xml file" and the same text for UpgradeSchema. Legacy scripts are therefore for reading existing code, not for writing new code.

Converting with --convert-old-scripts

The Schema Listener tool records the DDL that the old scripts run through the Magento connection and writes a db_schema.xml for the module. It is switched on by bin/magento setup:upgrade --convert-old-scripts=1 (also on setup:install and on setup:db-schema:upgrade), only works on a database where the scripts run, should not run in production mode, and ignores raw SQL, custom DDL and statements in recurring scripts. After the run on the legacy module, etc/db_schema.xml appeared:

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
  <table name="pocketcoffee_legacy_lot" resource="default" engine="innodb" comment="Legacy roast lots">
    <column xsi:type="int" name="lot_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Lot ID"/>
    <column xsi:type="varchar" name="sku" nullable="false" length="64" comment="SKU"/>
    <column xsi:type="date" name="roasted_at" comment="Roast date"/>
    <column xsi:type="decimal" name="weight_kg" scale="3" precision="8" unsigned="false" nullable="false" default="0" comment="Weight in kg"/>
    <constraint xsi:type="primary" referenceId="PRIMARY">
      <column name="lot_id"/>
    </constraint>
    <index referenceId="POCKETCOFFEE_LEGACY_LOT_SKU" indexType="btree">
      <column name="sku"/>
    </index>
  </table>
</schema>

The conversion is a starting point and needs a review, because it is not exact. In this run two differences stand out: the date column lost its nullable="false" (the declaration says nullable by default), and the integer column got a padding. The first one is a trap. setup:db:status reported a modify_column difference for roasted_at immediately, and because other upgrades ran in the shop while the converted file was in place, the real database column became date DEFAULT NULL before the file was fixed: NOT NULL was silently lost. The fix is to complete the XML, generate the whitelist and run the upgrade, which restored NOT NULL:

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
  <table name="pocketcoffee_legacy_lot" resource="default" engine="innodb" comment="Legacy roast lots">
    <column xsi:type="int" name="lot_id" unsigned="true" nullable="false" identity="true" comment="Lot ID"/>
    <column xsi:type="varchar" name="sku" nullable="false" length="64" comment="SKU"/>
    <column xsi:type="date" name="roasted_at" nullable="false" comment="Roast date"/>
    <column xsi:type="decimal" name="weight_kg" scale="3" precision="8" unsigned="false" nullable="false" default="0" comment="Weight in kg"/>
    <constraint xsi:type="primary" referenceId="PRIMARY">
      <column name="lot_id"/>
    </constraint>
    <index referenceId="POCKETCOFFEE_LEGACY_LOT_SKU" indexType="btree">
      <column name="sku"/>
    </index>
  </table>
</schema>

The rest of the migration, from the documentation: data scripts cannot be converted; write data patches (the stub command above) and, for a module that was already released, make each patch implement PatchVersionInterface with getVersion() equal to the version that the old script used, so that installations that already ran the old script skip the patch. The same interface is prohibited for new patches. Once a module uses data patches, it cannot go back to upgrade scripts, and setup_version can be dropped from module.xml when every script has been replaced. Release notes should say that the migration happened, because the setup_module table is no longer the history of the module.

Command reference

Command Purpose

bin/magento setup:upgrade

Declarative schema, patches and legacy scripts of all enabled modules; clears generated/. Options: --keep-generated, --safe-mode[=1], --data-restore[=1], --dry-run[=1], --convert-old-scripts[=1], --magento-init-params.

bin/magento setup:db:status

Reports declarative schema differences (as JSON), unapplied schema and data patches, and the legacy version check. Exit text says to run setup:upgrade.

bin/magento setup:db-schema:upgrade, bin/magento setup:db-data:upgrade

The schema half and the data half of the upgrade as separate commands.

bin/magento setup:db-declaration:generate-whitelist --module-name=<Vendor_Module>

Writes etc/db_schema_whitelist.json. all is the default.

bin/magento setup:db-declaration:generate-patch [--revertable=true] [--type=data|schema] <Module> <Patch>

Writes the skeleton of a patch.

bin/magento module:uninstall [--non-composer] [--remove-data] <Module>

Reverts the revertable data patches of the module (--remove-data for the data) and does not drop tables.

Since the 2016 books

The 2016 books describe the generation that this page replaces. The Magento 2 Cookbook has a recipe called "Creating tables using setup scripts" and one on database models, the Magento 2 DIY book installs with the Setup Wizard and the command line, and the Development Essentials book installs on XAMPP. What a reader coming from them should change:

  • Put new tables in db_schema.xml, and never in InstallSchema: the coding standard flags it as obsolete.

  • Do not use setup_version or version_compare. The version of the module is the Composer version, and patches replace the conditions on it.

  • Seed data, EAV attributes and configuration belong in data patches, which run once and in a transaction.

  • The Web Setup Wizard of the 2016 installations was removed in 2.4; upgrades are composer commands followed by bin/magento setup:upgrade.

  • A removed column is removed only if the whitelist knows it. The books have no equivalent concept.

Pocket Coffee thread

The pocketcoffee_lot table is the shop’s roast log. The module creates it with foreign keys to the catalog, seeds three lots with a data patch, marks the old ones as sold out with a second patch that depends on the first, and keeps a summary view by origin in a schema patch. The service contract on the previous page reads and writes it, and the product page can show the roast date of the newest ready lot.

Pitfalls

Symptom Cause and remedy

A removed column is still in the table.

The whitelist did not contain it. Generate the whitelist before removing; restore the column if needed, regenerate and remove again.

A table remains after a module is disabled.

The module has no db_schema_whitelist.json.

Tables vanish when a module is disabled.

The module has a whitelist: the next setup:upgrade drops every whitelisted table of a disabled module. Back up first.

setup:db:status reports a difference that cannot be explained.

Read the JSON: it names the table, the element and the type of change. After a conversion, check nullable, defaults and padding.

A foreign key is refused.

One of the two tables is not declared in db_schema.xml, or the column types differ (signedness and size must match).

The upgrade runs for ages.

A rename or a column type change copies the table. Run --dry-run=1 first and read the statements; schedule big migrations.

A patch ran twice, or not at all.

Its class name changed without an alias, or its row was removed from patch_list by an uninstall or by hand.

module:uninstall did not remove the table.

It reverts only revertable data patches. Dropping a table is a change of the declarative schema: disable the module and upgrade, or remove the element.

A revert deleted rows of other tables.

A DELETE in revert() triggered a foreign key cascade. Use UPDATE, as the contract says.

Cyclomatic dependency during patch installation.

Two or more patches depend on each other. Remove the cycle.

A converted db_schema.xml changed a column on the first upgrade.

The converter does not write nullable="false". Review the file against SHOW CREATE TABLE before upgrading anything.

What was and was not run

Run on the local Mage-OS 3.6.0 Docker shop: xmllint of every db_schema.xml, module.xml and the other XML files against the Magento XSDs; the Magento2 PHP_CodeSniffer standard on the modules; setup:db:status, setup:upgrade --dry-run=1, --safe-mode=1, --data-restore=1 and --convert-old-scripts=1; setup:db-declaration:generate-whitelist and generate-patch; the creation of pocketcoffee_lot with its foreign key, the seed and MarkOldLotsSoldOut patches and the view; the lab experiments of the whitelist (add, widen, drop with and without the whitelist, rename a column, rename a table, drop a column of another module, disable the module to drop the table); the failing patch with its rollback, the dependency cycle and the alias; module:uninstall --non-composer --remove-data with the revert of a data patch and the next upgrade; and the legacy InstallSchema and UpgradeSchema module with its conversion and the repair of the converted file. Not run: split databases (resource values checkout and sales), a database with a table prefix, Composer-installed modules (module:uninstall without --non-composer), PatchVersionInterface for a released module, recurring scripts, the Web Setup Wizard and a production-mode deployment. Whitelist generation for all modules at once was not run, to avoid rewriting the files of the core.