Chat on WhatsApp
Magento Development 11 min read

Add a Custom Column to the Magento 2 Order Grid (the Non-Breaking Way)

Most order grid tutorials break filtering, sorting, or CSV export the day you paste them. Here are the three ways I add order grid columns on real stores, including the one Magento itself uses.

Add a Custom Column to the Magento 2 Order Grid (the Non-Breaking Way)

Nearly every broken order grid I get called in to fix started life as a copy-paste tutorial: a collection join bolted on in _renderFiltersBefore, and a month later filtering throws SQL errors and the CSV export times out. This is the way I add columns to the sales order grid on client stores: three approaches, chosen by where the data lives, all of them keeping filters, sorting, export, and dev/grid/async_indexing intact. Full working code under the Panth\OrderGrid namespace, tested on Magento 2.4.9, PHP 8.4 (July 2026).

3approaches compared: XML only, grid sync, live join
12seconds per grid load on a 400k order store with the naive join
0.8seconds after moving the same column into sales_order_grid

Why the order grid is not like other admin grids

The admin orders page never reads sales_order. It reads sales_order_grid, a flat table Magento maintains for exactly one reason: so the busiest grid in the admin never has to join anything at page load. On every order save, Magento copies a row from sales_order (plus a few joined tables like sales_order_address and sales_order_payment) into sales_order_grid. On busy stores, admins turn on dev/grid/async_indexing and a cron job does the same copy in batches instead, so checkout never waits on grid writes.

The copy is performed by the resource model behind the virtualType Magento\Sales\Model\ResourceModel\Order\Grid, and the SELECT it copies with is built from two di.xml arguments: joins and columns. Both are arrays, and array arguments in di.xml merge across modules. That is the extension point. You do not need a plugin, an observer, or an override to get custom data into this table; you declare one more item in an array and Magento's own sync carries your value along, in both sync and async mode.

Keep that model in your head and the three approaches below sort themselves: use the flat table if the data is already there, push your data into the flat table if you own it, and only join at read time when the data truly cannot live in the flat table.

The 12 second order grid: a cleanup story

Last winter I picked up a cleanup job on a fashion store with about 400,000 orders. The admin orders page took 12 seconds to render. It had once loaded in under a second, and nobody could say when that stopped being true.

The previous developer had needed an ERP sync status next to each order. He followed one of the popular tutorials (Meetanshi, Mageplaza, MageComp and MageWorx all publish some flavor of it) and joined his status table onto the grid collection inside _renderFiltersBefore, plus a second join back to sales_order for two extra fields. Three things went wrong at once:

  • The joins ran on every single grid page load. With the default sort of newest first across a joined result set, MySQL gave up on the index and did a temporary table plus filesort over hundreds of thousands of rows.
  • Filtering by purchase date threw Column 'created_at' in where clause is ambiguous, because the same column now existed on both sides of the join and nothing told the collection which one the filter meant.
  • CSV export walked the entire joined result without pagination and died on max_execution_time. The team had quietly stopped exporting orders months earlier.

The fix was not a better join. I moved the status into sales_order_grid using the sync mechanism below, deleted the plugin, and the grid came back at 0.8 seconds with working filters and a working export. It is the same disease I keep finding on slow category pages: a join bolted onto a hot query instead of denormalizing the data once, at write time. I wrote up the frontend version of that trap in the category page EAV join post; the order grid is the admin flavor.

If a tutorial tells you to join inside _renderFiltersBefore, close the tab. It is the wrong layer: filters map wrong, sorting goes ambiguous, export inherits the join, and none of it survives a store with real order volume.

Step 0: the module skeleton

Everything below lives in one small module, Panth_OrderGrid. I will not repeat registration.php and module.xml here; if you need the boilerplate walkthrough it is in my create-a-module-from-scratch guide. The full file layout for this post:

app/code/Panth/OrderGrid/
  registration.php
  etc/
    module.xml
    di.xml
    db_schema.xml
    db_schema_whitelist.json
  view/adminhtml/ui_component/
    sales_order_grid.xml
  Model/ResourceModel/Order/Grid/
    Collection.php        (approach 3 only)

How to add a custom column to the sales order grid in Magento 2: the simplest case

First, check whether your value is already in the flat table:

docker compose exec db mysql -u magento -p magento -e "DESCRIBE sales_order_grid;"

You might be surprised what is already there. customer_email, payment_method, shipping_information, total_refunded, customer_group and more get synced on every install, and plenty of them are not shown by default. If your column exists in this table, the entire job is one XML file and you should stop after this section.

Magento 2 sales_order_grid.xml example

Create view/adminhtml/ui_component/sales_order_grid.xml. Magento merges this with the core component of the same name, so you declare only your addition:

<?xml version="1.0"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <columns name="sales_order_columns">
        <column name="delivery_note">
            <settings>
                <filter>text</filter>
                <label translate="true">Delivery Note</label>
                <visible>true</visible>
            </settings>
        </column>
    </columns>
</listing>

The name attribute must match the column name in sales_order_grid exactly; that string is how the data provider, the filter, and the export all find the value. Flush config cache and the column appears, filterable and sortable for free, because it is a plain column on the flat table. Use <filter>dateRange</filter> for dates and <filter>select</filter> plus an options class for enum-like values.

Magento 2 add column to order grid without extension: the grid sync way

Now the case the tutorials get wrong: the value is your own custom data. The robust answer is to give sales_order_grid a real column and let Magento's own sync fill it. Three small files, no PHP classes.

1. Declare the column in db_schema.xml

Add it to both tables: sales_order is where your code writes the value, sales_order_grid is where the grid reads it. Index the grid column, since admins will filter on it:

<?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="sales_order">
        <column xsi:type="varchar" name="delivery_note" nullable="true" length="255"
                comment="Delivery Note"/>
    </table>
    <table name="sales_order_grid">
        <column xsi:type="varchar" name="delivery_note" nullable="true" length="255"
                comment="Delivery Note"/>
        <index referenceId="SALES_ORDER_GRID_DELIVERY_NOTE" indexType="btree">
            <column name="delivery_note"/>
        </index>
    </table>
</schema>

2. Tell the sync about it in di.xml

One virtualType argument. Because di.xml arrays merge, you declare only your item and the core columns stay untouched:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <virtualType name="Magento\Sales\Model\ResourceModel\Order\Grid">
        <arguments>
            <argument name="columns" xsi:type="array">
                <item name="delivery_note" xsi:type="string">sales_order.delivery_note</item>
            </argument>
        </arguments>
    </virtualType>
</config>

That is the whole trick. From now on, every order save (or every async indexing cron run) copies sales_order.delivery_note into the grid row along with everything else. Add the ui_component XML from the previous section, then deploy:

php bin/magento setup:db-declaration:generate-whitelist --module-name=Panth_OrderGrid
php bin/magento setup:upgrade
php bin/magento cache:clean config

3. Backfill historical orders once

The sync only fires when an order is saved, so old orders keep a NULL grid cell until you backfill:

UPDATE sales_order_grid g
INNER JOIN sales_order o ON o.entity_id = g.entity_id
SET g.delivery_note = o.delivery_note
WHERE o.delivery_note IS NOT NULL;

Magento 2 order grid join custom table, but at sync time

What if the value lives in your own table, not on sales_order? Same virtualType, one more argument. This is exactly how core pulls payment_method from sales_order_payment:

<virtualType name="Magento\Sales\Model\ResourceModel\Order\Grid">
    <arguments>
        <argument name="joins" xsi:type="array">
            <item name="panth_delivery" xsi:type="array">
                <item name="table" xsi:type="string">panth_delivery_meta</item>
                <item name="origin_column" xsi:type="string">entity_id</item>
                <item name="target_column" xsi:type="string">order_id</item>
            </item>
        </argument>
        <argument name="columns" xsi:type="array">
            <item name="delivery_note" xsi:type="string">panth_delivery.delivery_note</item>
        </argument>
    </arguments>
</virtualType>

Yes, this is a join. The difference that matters: it runs once per order at write time, against a handful of rows, instead of on every admin page load against the whole table. The grid itself still reads one flat, indexed table. This is the approach I used on the 400k order cleanup, and it is what I ship in client work through my extension development service whenever an order grid column comes up.

When you actually need a live join: the SearchResult collection

Sometimes the data really cannot be denormalized: it changes outside Magento, it belongs to another module's table you do not want to mirror, or the column is only needed for a week of debugging. Then you join at read time, but at the right layer: the grid's data source collection, registered through di.xml so the normal grid and the export both use it.

Create Model/ResourceModel/Order/Grid/Collection.php:

<?php
declare(strict_types=1);

namespace Panth\OrderGrid\Model\ResourceModel\Order\Grid;

use Magento\Sales\Model\ResourceModel\Order\Grid\Collection as OrderGridCollection;

class Collection extends OrderGridCollection
{
    protected function _initSelect()
    {
        parent::_initSelect();
        $this->getSelect()->joinLeft(
            ['pdm' => $this->getTable('panth_delivery_meta')],
            'pdm.order_id = main_table.entity_id',
            ['delivery_note']
        );
        // Without this line, filtering the column throws an SQL error.
        $this->addFilterToMap('delivery_note', 'pdm.delivery_note');

        return $this;
    }

    public function setOrder($field, $direction = self::SORT_ORDER_DESC)
    {
        // Sorting does not go through the filter map; alias it here.
        if ($field === 'delivery_note') {
            $field = 'pdm.delivery_note';
        }
        return parent::setOrder($field, $direction);
    }
}

Then point the grid's data source at your class in etc/di.xml. The collections array on the CollectionFactory is where core registers sales_order_grid_data_source, and your module's entry wins the merge:

<type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory">
    <arguments>
        <argument name="collections" xsi:type="array">
            <item name="sales_order_grid_data_source" xsi:type="string">Panth\OrderGrid\Model\ResourceModel\Order\Grid\Collection</item>
        </argument>
    </arguments>
</type>

Add the same ui_component XML as before, run setup:upgrade, done. Keep the joined table small and give order_id an index, or you are rebuilding the 12 second grid from my war story one page load at a time.

Magento 2 order grid column not filtering? The filter map is the fix

This is the number one comment under every competitor tutorial. The symptoms: typing in the column filter throws Column not found: 1054 Unknown column 'delivery_note' in 'where clause', or an ambiguous column error, or the grid silently returns zero rows. The cause: the UI component sends the filter as a bare field name, and the collection prefixes it with main_table. by default. Your column does not live on main_table, so the WHERE clause points at nothing.

addFilterToMap('delivery_note', 'pdm.delivery_note') translates the bare name to the aliased one before the WHERE is built. Interesting detail from the 2.4.9 core: the order grid collection already runs describeTable() on sales_order_grid and maps every flat table column to main_table.* automatically, which is why core columns never break. Only your joined columns are unmapped, so only they explode. Sorting is a separate path through setOrder(), which is why the override above aliases the field there too.

The CSV and Excel export gotcha

Grid export (mui/export/gridToCsv) does not screenshot what you see. It builds the same named data source through the same CollectionFactory and streams every matching row. Two consequences:

  • Both di.xml approaches above export correctly with zero extra work, because export resolves the identical collection, join, and filter map.
  • Anything that injects data after the collection loads (an observer on the collection load event, a plugin on the data provider's getData(), a renderer that fetches per row in JS) exports blank cells, because export never runs that layer.

One more export wrinkle: a select filter column exports the raw stored value, not the label, unless your column declares an options class in the ui_component XML. Declare <options class="Panth\OrderGrid\Model\Source\DeliveryType"/> style options and the export writes labels. If your exported CSV shows numbers where the grid shows words, this is why.

Which approach should you use?

ApproachFiltering and sortingCSV / Excel exportCost at 400k ordersUse when
ui_component XML onlyWorks out of the boxWorksNone, flat indexed tableValue already exists in sales_order_grid
Grid sync virtualType (columns / joins)Works out of the boxWorksOne indexed column, join cost paid at write timeYour own order data; the correct default
Live join via SearchResult collectionWorks only with addFilterToMap + setOrder fixesWorks (same collection)Join on every page load; needs tight indexesExternal or volatile data you cannot mirror
Join in _renderFiltersBefore (tutorial way)Breaks with ambiguous column errorsTimes out or exports wrong rows12 second page loadsNever

Verdict: default to the grid sync virtualType. It is the mechanism Magento itself uses for every core column, it costs one di.xml block plus a db_schema entry, and it is the only approach that stays fast under async grid indexing on high volume stores. Reach for the live join only when the data honestly cannot live in the flat table, and budget the filter map fixes when you do.

Frequently asked questions

How do I add a custom column to the sales order grid in Magento 2?

Two files in the common case: a db_schema.xml entry adding the column to sales_order_grid (and sales_order if that is the source), plus a di.xml virtualType item on Magento\Sales\Model\ResourceModel\Order\Grid mapping the column, then a sales_order_grid.xml ui_component file to display it. Magento's own grid sync fills the value on every order save, so filtering, sorting, and export work without custom PHP.

How do I join a custom table to the order grid in Magento 2?

Prefer joining at sync time: add a joins item plus a columns item to the Magento\Sales\Model\ResourceModel\Order\Grid virtualType in di.xml, and the join runs once per order at write time. If the data truly cannot be mirrored, extend Magento\Sales\Model\ResourceModel\Order\Grid\Collection, add your joinLeft in _initSelect, call addFilterToMap for each joined column, and register the class as sales_order_grid_data_source through the CollectionFactory collections argument.

Where does sales_order_grid.xml go and what must it contain?

Put it at view/adminhtml/ui_component/sales_order_grid.xml inside your module. Because ui_component files with the same name merge, it only needs the listing root node, a columns node named sales_order_columns, and your column with its filter type and label. The column name attribute must match the sales_order_grid table column exactly.

Why is my Magento 2 order grid column not filtering or sorting?

Because the column comes from a join and the collection maps bare filter names to main_table by default, so the generated WHERE or ORDER BY references a column that does not exist there. Fix filtering with addFilterToMap('your_column', 'alias.your_column') in the collection, and fix sorting by aliasing the field in a setOrder() override. Columns synced into sales_order_grid never have this problem.

Can I add a column to the order grid without an extension?

Without a third party extension, yes: the grid sync approach in this post is plain Magento configuration inside a five file module you own. Entirely without code, no: sales_order_grid.xml and di.xml must live in a module (or a theme override for the XML part). There is no admin toggle that adds arbitrary columns.

Why is my new order grid column blank for existing orders?

The grid sync only fires when an order is saved, so historical rows keep NULL until you backfill. Run a one-off UPDATE joining sales_order_grid to your source table, or resave the affected orders. Also check the ui_component column name matches the table column exactly, and that you added the column to sales_order_grid, not only to sales_order.

Order grid broken after a tutorial copy-paste? I untangle admin grid customizations for a living: ambiguous column errors, 10 second order pages, exports that time out. I will audit the grid, move the data to the right layer, and leave you with filters and CSV export that actually work. Fixed fee from $499 audit · $2,499 sprint · ~4h @ $25/hr for a typical grid column job.

Fix my order grid