Commit 5d1d85e0e73 for woocommerce

commit 5d1d85e0e73e5df1a6a6298dba5813fe7d97c157
Author: Brian <brian@brianhaas.li>
Date:   Mon Oct 26 18:28:33 2026 +0100

    Add MPN (manufacturer part / product number) as a valid product field


    ---------

    Co-authored-by: Lucio Giannotta <lucio.giannotta@a8c.com>

diff --git a/docs/apis/rest-api/v2/products.mdx b/docs/apis/rest-api/v2/products.mdx
index 085595cc1c8..6d5990b3d25 100644
--- a/docs/apis/rest-api/v2/products.mdx
+++ b/docs/apis/rest-api/v2/products.mdx
@@ -27,6 +27,7 @@ The products API allows you to create, view, update, and delete individual, or a
 | `description`           | string    | Product description.                                                                                                 |       |                                                                               |
 | `short_description`     | string    | Product short description.                                                                                           |       |                                                                               |
 | `sku`                   | string    | Unique identifier.                                                                                                   |       |                                                                               |
+| `mpn`                   | string    | Manufacturer part number.                                                                                            |       |                                                                               |
 | `price`                 | string    | Current product price. `READ-ONLY`                                                     |       |                                                                               |
 | `regular_price`         | string    | Product regular price.                                                                                               |       |                                                                               |
 | `sale_price`            | string    | Product sale price.                                                                                                  |       |                                                                               |
diff --git a/docs/apis/rest-api/v3/product-variations.mdx b/docs/apis/rest-api/v3/product-variations.mdx
index 62b33dc6a43..38ef4e894fd 100644
--- a/docs/apis/rest-api/v3/product-variations.mdx
+++ b/docs/apis/rest-api/v3/product-variations.mdx
@@ -22,6 +22,7 @@ The product variations API allows you to create, view, update, and delete indivi
 | `description`           | string          | Variation description.                                                                                              |
 | `permalink`             | string          | Variation URL. `READ-ONLY`                                                            |
 | `sku`                   | string          | Unique identifier.                                                                                                  |
+| `mpn`                   | string          | Manufacturer part number.                                                                                           |
 | `global_unique_id`      | string          | GTIN, UPC, EAN or ISBN. A global unique identifier for the variation.                                               |
 | `customs_commodity_code` | string, null | Customs code containing 6–14 digits after spaces and punctuation are removed. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
 | `customs_country_of_origin` | string, null | Two-letter ISO country of origin code. The default `view` context returns the parent value when the variation has none; `context=edit` returns the stored value, `null` when inherited. |
@@ -687,6 +688,7 @@ woocommerce.get("products/22/variations").parsed_response
 | `include_status` | string  | Limit result set to product variations with any of the specified statuses. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `status` parameter. Options: `any`, `future`, `trash`, `draft`, `pending`, `private`, and `publish`.      |
 | `exclude_status` | string  | Exclude product variations from result set with any of the specified statuses. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `include_status` parameter. Options: `future`, `trash`, `draft`, `pending`, `private`, and `publish`. |
 | `sku`            | string  | Limit result set to products with a specific SKU.                                                                                                                                                                                                                           |
+| `mpn`            | string  | Limit results to exact MPN matches. Separate multiple values with commas.                                                                                                                                                                                                   |
 | `tax_class`      | string  | Limit result set to products with a specific tax class. Default options: `standard`, `reduced-rate` and `zero-rate`.                                                                                                                                                        |
 | `on_sale`        | boolean | Limit result set to products on sale.                                                                                                                                                                                                                                       |
 | `min_price`      | string  | Limit result set to products based on a minimum price.                                                                                                                                                                                                                      |
@@ -1542,4 +1544,4 @@ woocommerce.post("products/22/variations/batch", data).parsed_response
 ```

   </TabItem>
-</Tabs>
\ No newline at end of file
+</Tabs>
diff --git a/docs/apis/rest-api/v3/products.mdx b/docs/apis/rest-api/v3/products.mdx
index f9e238a7d08..d70b26a0a61 100644
--- a/docs/apis/rest-api/v3/products.mdx
+++ b/docs/apis/rest-api/v3/products.mdx
@@ -29,6 +29,7 @@ The products API allows you to create, view, update, and delete individual, or a
 | `description`           | string    | Product description.                                                                                                                                                                                                                   |
 | `short_description`     | string    | Product short description.                                                                                                                                                                                                             |
 | `sku`                   | string    | Unique identifier.                                                                                                                                                                                                                     |
+| `mpn`                   | string    | Manufacturer part number.                                                                                                                                                                                                              |
 | `global_unique_id`      | string    | GTIN, UPC, EAN or ISBN - a unique identifier for each distinct product and service that can be purchased.                                                                                                                              |
 | `customs_commodity_code` | string, null | Optional customs code containing 6–14 digits after spaces and punctuation are removed. Letters are rejected. |
 | `customs_country_of_origin` | string, null | Optional two-letter ISO country of origin code; normalized to uppercase. |
@@ -1507,7 +1508,7 @@ woocommerce.get("products").parsed_response
 | `page`            | integer | Current page of the collection. Default is `1`.                                                                                                                                                                                                                                                      |
 | `per_page`        | integer | Maximum number of items to be returned in result set. Default is `10`.                                                                                                                                                                                                                               |
 | `search`          | string  | Limit results to those matching a string.                                                                                                                                                                                                                                                            |
-| `search_fields`   | array   | Fields to search when used with `search` parameter. All search tokens must be found across any of the specified fields. Takes precedence over other search parameters. If empty, uses default `search` behavior. Options: `name`, `sku`, `global_unique_id`, `description`, and `short_description`. |
+| `search_fields`   | array   | Fields to search when used with `search` parameter. All search tokens must be found across any of the specified fields. Takes precedence over other search parameters. If empty, uses default `search` behavior. Options: `name`, `sku`, `global_unique_id`, `mpn`, `description`, and `short_description`. |
 | `after`           | string  | Limit response to resources published after a given ISO8601 compliant date.                                                                                                                                                                                                                          |
 | `before`          | string  | Limit response to resources published before a given ISO8601 compliant date.                                                                                                                                                                                                                         |
 | `modified_after`  | string  | Limit response to resources modified after a given ISO8601 compliant date.                                                                                                                                                                                                                           |
@@ -1528,6 +1529,8 @@ woocommerce.get("products").parsed_response
 | `include_types`   | string  | Limit result set to products with any of the types. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `type` parameter. Options: `simple`, `grouped`, `external` and `variable`.                                                                                |
 | `exclude_types`   | string  | Exclude products from result set with any of the specified types. Multiple statuses can be provided as a comma-separated list. Takes precedence over the `include_types` parameter. Options: `simple`, `grouped`, `external` and `variable`.                                                         |
 | `sku`             | string  | Limit result set to products with a specific SKU.                                                                                                                                                                                                                                                    |
+| `mpn`             | string  | Limit results to exact MPN matches. Separate multiple values with commas.                                                                                                                                                                                                                            |
+| `search_mpn`      | string  | Limit results to partial MPN matches within the first 100 characters. Takes precedence over `mpn`; combines with name and SKU search filters. `search` with `search_fields` takes precedence over `search_mpn`.                                                                                      |
 | `featured`        | boolean | Limit result set to featured products.                                                                                                                                                                                                                                                               |
 | `category`        | string  | Limit result set to products assigned a specific category ID.                                                                                                                                                                                                                                        |
 | `tag`             | string  | Limit result set to products assigned a specific tag ID.                                                                                                                                                                                                                                             |
diff --git a/plugins/woocommerce/changelog/60286-mpn-feature b/plugins/woocommerce/changelog/60286-mpn-feature
new file mode 100644
index 00000000000..a6d74507659
--- /dev/null
+++ b/plugins/woocommerce/changelog/60286-mpn-feature
@@ -0,0 +1,4 @@
+Significance: major
+Type: add
+
+Introduce MPN field (manufacturer part / product number) field to WooCommerce
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 6142b6ef5f6..875b4d586db 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -73,6 +73,7 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		'short_description'         => '',
 		'sku'                       => '',
 		'global_unique_id'          => '',
+		'mpn'                       => '',
 		'price'                     => '',
 		'regular_price'             => '',
 		'sale_price'                => '',
@@ -284,6 +285,17 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		return $this->get_prop( 'global_unique_id', $context );
 	}

+	/**
+	 * Get MPN.
+	 *
+	 * @since 11.4.0
+	 * @param  string $context What the value is for. Valid values are view and edit.
+	 * @return string
+	 */
+	public function get_mpn( $context = 'view' ) {
+		return $this->get_prop( 'mpn', $context );
+	}
+
 	/**
 	 * Gets the customs commodity code.
 	 *
@@ -956,6 +968,17 @@ class WC_Product extends WC_Abstract_Legacy_Product {
 		$this->set_prop( 'global_unique_id', $global_unique_id );
 	}

+	/**
+	 * Set MPN.
+	 *
+	 * @since 11.4.0
+	 * @param string $mpn Product MPN.
+	 * @return void
+	 */
+	public function set_mpn( $mpn ) {
+		$this->set_prop( 'mpn', (string) $mpn );
+	}
+
 	/**
 	 * Sets the customs commodity code.
 	 *
diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
index 3ec84e250f8..c19ad9ae2e9 100644
--- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
+++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php
@@ -779,6 +779,7 @@ class WC_Product_CSV_Importer_Controller {
 			__( 'Type', 'woocommerce' )                    => 'type',
 			__( 'SKU', 'woocommerce' )                     => 'sku',
 			__( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ) => 'global_unique_id',
+			__( 'MPN', 'woocommerce' )                     => 'mpn',
 			__( 'Name', 'woocommerce' )                    => 'name',
 			__( 'Published', 'woocommerce' )               => 'published',
 			__( 'Is featured?', 'woocommerce' )            => 'featured',
@@ -961,6 +962,7 @@ class WC_Product_CSV_Importer_Controller {
 			'type'               => __( 'Type', 'woocommerce' ),
 			'sku'                => __( 'SKU', 'woocommerce' ),
 			'global_unique_id'   => __( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ),
+			'mpn'                => __( 'MPN', 'woocommerce' ),
 			'name'               => __( 'Name', 'woocommerce' ),
 			'published'          => __( 'Published', 'woocommerce' ),
 			'featured'           => __( 'Is featured?', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
index 2d2055a3724..6ff8d3b82f7 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-product-data.php
@@ -375,6 +375,7 @@ class WC_Meta_Box_Product_Data {
 			array(
 				'sku'                       => isset( $_POST['_sku'] ) ? wc_clean( wp_unslash( $_POST['_sku'] ) ) : null,
 				'global_unique_id'          => isset( $_POST['_global_unique_id'] ) ? wc_clean( wp_unslash( $_POST['_global_unique_id'] ) ) : null,
+				'mpn'                       => isset( $_POST['_mpn'] ) ? wc_clean( wp_unslash( $_POST['_mpn'] ) ) : null,
 				'purchase_note'             => isset( $_POST['_purchase_note'] ) ? wp_kses_post( wp_unslash( $_POST['_purchase_note'] ) ) : '',
 				'downloadable'              => isset( $_POST['_downloadable'] ),
 				'virtual'                   => isset( $_POST['_virtual'] ),
@@ -601,6 +602,7 @@ class WC_Meta_Box_Product_Data {
 						'attributes'                => self::prepare_set_attributes( $parent->get_attributes(), 'attribute_', $i ),
 						'sku'                       => isset( $_POST['variable_sku'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_sku'][ $i ] ) ) : '',
 						'global_unique_id'          => isset( $_POST['variable_global_unique_id'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_global_unique_id'][ $i ] ) ) : '',
+						'mpn'                       => isset( $_POST['variable_mpn'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_mpn'][ $i ] ) ) : null,
 						'weight'                    => isset( $_POST['variable_weight'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_weight'][ $i ] ) ) : '',
 						'length'                    => isset( $_POST['variable_length'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_length'][ $i ] ) ) : '',
 						'width'                     => isset( $_POST['variable_width'][ $i ] ) ? wc_clean( wp_unslash( $_POST['variable_width'][ $i ] ) ) : '',
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php
index 32e1d64ba18..690e8f60992 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-inventory.php
@@ -41,6 +41,23 @@ if ( ! defined( 'ABSPATH' ) ) {

 		do_action( 'woocommerce_product_options_global_unique_id' );

+		woocommerce_wp_text_input(
+			array(
+				'id'          => '_mpn',
+				'value'       => $product_object->get_mpn( 'edit' ), // @phpstan-ignore variable.undefined (The including meta box supplies $product_object.)
+				'label'       => '<abbr title="' . esc_attr__( 'Manufacturer part number', 'woocommerce' ) . '">' . esc_html__( 'MPN', 'woocommerce' ) . '</abbr>',
+				'desc_tip'    => true,
+				'description' => __( 'Enter the manufacturer part number for this product.', 'woocommerce' ),
+			)
+		);
+
+		/**
+		 * Action hook after the MPN (Manufacturer part number) product option field.
+		 *
+		 * @since 11.4.0
+		 */
+		do_action( 'woocommerce_product_options_mpn' );
+
 		?>
 		<div class="inline notice woocommerce-message show_if_variable">
 			<img class="info-icon" src="<?php echo esc_url( $info_img_url ); ?>" />
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
index 03819ce8c52..0c29d71f207 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
@@ -118,6 +118,18 @@ defined( 'ABSPATH' ) || exit;
 						'wrapper_class' => 'form-row',
 					)
 				);
+
+				woocommerce_wp_text_input(
+					array(
+						'id'            => "variable_mpn{$loop}", // @phpstan-ignore variable.undefined (The including meta box supplies $loop.)
+						'name'          => "variable_mpn[{$loop}]", // @phpstan-ignore variable.undefined (The including meta box supplies $loop.)
+						'value'         => $variation_object->get_mpn( 'edit' ),
+						'label'         => '<abbr title="' . esc_attr__( 'Manufacturer part number', 'woocommerce' ) . '">' . esc_html__( 'MPN', 'woocommerce' ) . '</abbr>',
+						'desc_tip'      => true,
+						'description'   => __( 'Enter the manufacturer part number for this variation.', 'woocommerce' ),
+						'wrapper_class' => 'form-row',
+					)
+				);
 				?>
 				</div>
 			</div>
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index e558f0476e6..0d2d83c3094 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -374,6 +374,7 @@ class WC_Install {
 		),
 		'11.4.0'   => array(
 			'wc_update_1140_re_enable_autoload_options',
+			'wc_update_1140_add_mpn_to_product_lookup_table',
 		),
 	);

@@ -2059,6 +2060,7 @@ CREATE TABLE {$wpdb->prefix}wc_product_meta_lookup (
   `product_id` bigint(20) NOT NULL,
   `sku` varchar(100) NULL default '',
   `global_unique_id` varchar(100) NULL default '',
+  `mpn` varchar(100) NOT NULL DEFAULT '',
   `virtual` tinyint(1) NULL default 0,
   `downloadable` tinyint(1) NULL default 0,
   `min_price` decimal(19,4) NULL default NULL,
@@ -2079,7 +2081,8 @@ CREATE TABLE {$wpdb->prefix}wc_product_meta_lookup (
   KEY `onsale` (`onsale`),
   KEY min_max_price (`min_price`, `max_price`),
   KEY sku (sku(50)),
-  KEY global_unique_id (global_unique_id(50))
+  KEY global_unique_id (global_unique_id(50)),
+  KEY mpn (mpn(50))
 ) $collate;
 CREATE TABLE {$wpdb->prefix}wc_tax_rate_classes (
   tax_rate_class_id bigint(20) unsigned NOT NULL auto_increment,
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index 555a85e5c25..d9cca8be83d 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -261,6 +261,11 @@ class WC_Structured_Data {
 			$markup['gtin'] = $gtin;
 		}

+		$mpn = $product->get_mpn();
+		if ( '' !== $mpn ) {
+			$markup['mpn'] = $mpn;
+		}
+
 		if ( '' !== $product->get_price() ) {
 			// Assume prices will be valid until the end of next year, unless on sale and there is an end date.
 			$price_valid_until = gmdate( 'Y-12-31', time() + YEAR_IN_SECONDS );
@@ -291,6 +296,13 @@ class WC_Structured_Data {
 				} else {
 					unset( $markup['gtin'] );
 				}
+
+				$variation_mpn = $selected_variation->get_mpn();
+				if ( '' !== $variation_mpn ) {
+					$markup['mpn'] = $variation_mpn;
+				} else {
+					unset( $markup['mpn'] );
+				}
 			}

 			if ( $product->is_type( ProductType::VARIABLE ) && ! $selected_variation ) {
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 62ce80550a6..1cfa3cf2c3e 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -73,7 +73,7 @@ final class WooCommerce {
 	 *
 	 * @var string
 	 */
-	public $db_version = '920';
+	public $db_version = '1140';

 	/**
 	 * The single instance of the class.
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index b9124804597..89a87788ab7 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -36,6 +36,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		'_visibility',
 		'_sku',
 		'_global_unique_id',
+		'_mpn',
 		'_customs_commodity_code',
 		'_customs_country_of_origin',
 		'_customs_description',
@@ -453,6 +454,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		$meta_key_to_props = array(
 			'_sku'                       => 'sku',
 			'_global_unique_id'          => 'global_unique_id',
+			'_mpn'                       => 'mpn',
 			'_regular_price'             => 'regular_price',
 			'_sale_price'                => 'sale_price',
 			'_price'                     => 'price',
@@ -742,6 +744,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 		$meta_key_to_props = array(
 			'_sku'                       => 'sku',
 			'_global_unique_id'          => 'global_unique_id',
+			'_mpn'                       => 'mpn',
 			'_regular_price'             => 'regular_price',
 			'_sale_price'                => 'sale_price',
 			'_sale_price_dates_from'     => 'date_on_sale_from',
@@ -975,7 +978,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 			}
 		}

-		$props_in_lookup_table = array( 'sku', 'global_unique_id', 'regular_price', 'sale_price', 'date_on_sale_from', 'date_on_sale_to', 'total_sales', 'average_rating', 'stock_quantity', 'stock_status', 'manage_stock', 'downloadable', 'virtual', 'tax_status', 'tax_class' );
+		$props_in_lookup_table = array( 'sku', 'global_unique_id', 'mpn', 'regular_price', 'sale_price', 'date_on_sale_from', 'date_on_sale_to', 'total_sales', 'average_rating', 'stock_quantity', 'stock_status', 'manage_stock', 'downloadable', 'virtual', 'tax_status', 'tax_class' );
 		if ( $this->cogs_feature_is_enabled() ) {
 			$props_in_lookup_table[] = 'cogs_value';
 		}
@@ -2675,6 +2678,11 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
 			if ( get_option( 'woocommerce_schema_version', 0 ) >= 920 ) {
 				$product_data['global_unique_id'] = get_post_meta( $id, '_global_unique_id', true );
 			}
+			if ( get_option( 'woocommerce_schema_version', 0 ) >= 1140 ) {
+				$raw_mpn = (string) get_post_meta( $id, '_mpn', true );
+				// Ensure we don't exceed lookup column size (varchar 100).
+				$product_data['mpn'] = mb_substr( $raw_mpn, 0, 100 );
+			}
 			return $product_data;
 		}
 		return array();
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
index 8c4f5c2f247..0526ba33f66 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variation-data-store-cpt.php
@@ -427,6 +427,7 @@ class WC_Product_Variation_Data_Store_CPT extends WC_Product_Data_Store_CPT impl
 			'_downloadable'              => 'downloadable',
 			'_sku'                       => 'sku',
 			'_global_unique_id'          => 'global_unique_id',
+			'_mpn'                       => 'mpn',
 			'_stock'                     => 'stock_quantity',
 			'_weight'                    => 'weight',
 			'_length'                    => 'length',
diff --git a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
index 40707ff6db8..5d6274be041 100644
--- a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
+++ b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
@@ -137,6 +137,7 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {
 			'type'               => __( 'Type', 'woocommerce' ),
 			'sku'                => __( 'SKU', 'woocommerce' ),
 			'global_unique_id'   => __( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ),
+			'mpn'                => __( 'MPN', 'woocommerce' ),
 			'name'               => __( 'Name', 'woocommerce' ),
 			'published'          => __( 'Published', 'woocommerce' ),
 			'featured'           => __( 'Is featured?', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index d3cb94cccef..eba2d1add83 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -899,6 +899,9 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
 				case 'sku':
 					$base_data['sku'] = $product->get_sku( $context );
 					break;
+				case 'mpn':
+					$base_data['mpn'] = $product->get_mpn( $context );
+					break;
 				case 'price':
 					$base_data['price'] = $product->get_price( $context );
 					break;
@@ -1176,6 +1179,11 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
 			$product->set_sku( wc_clean( $request['sku'] ) );
 		}

+		// MPN.
+		if ( isset( $request['mpn'] ) ) {
+			$product->set_mpn( sanitize_text_field( $request['mpn'] ) );
+		}
+
 		// Attributes.
 		if ( isset( $request['attributes'] ) ) {
 			$attributes = array();
@@ -1939,6 +1947,11 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 				),
+				'mpn'                   => array(
+					'description' => __( 'Manufacturer part number.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
 				'price'                 => array(
 					'description' => __( 'Current product price.', 'woocommerce' ),
 					'type'        => 'string',
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 5f42650c3f2..231cbeaba3e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -128,6 +128,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			'permalink'             => $object->get_permalink(),
 			'sku'                   => $object->get_sku(),
 			'global_unique_id'      => $object->get_global_unique_id(),
+			'mpn'                   => $object->get_mpn(), // @phpstan-ignore method.notFound (This endpoint receives a variation; the generic WC_Data annotation is broader.)
 			'price'                 => $object->get_price(),
 			'regular_price'         => $object->get_regular_price(),
 			'sale_price'            => $object->get_sale_price(),
@@ -236,6 +237,11 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			$variation->set_global_unique_id( wc_clean( $request['global_unique_id'] ) );
 		}

+		// MPN.
+		if ( $variation instanceof WC_Product && isset( $request['mpn'] ) ) {
+			$variation->set_mpn( sanitize_text_field( $request['mpn'] ) );
+		}
+
 		// Thumbnail.
 		if ( isset( $request['image'] ) ) {
 			if ( is_array( $request['image'] ) ) {
@@ -621,6 +627,11 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 				),
+				'mpn'                   => array(
+					'description' => __( 'Manufacturer part number.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
 				'price'                 => array(
 					'description' => __( 'Current variation price.', 'woocommerce' ),
 					'type'        => 'string',
@@ -1097,6 +1108,19 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			);
 		}

+		// Filter by mpn.
+		if ( '' !== ( $request['mpn'] ?? '' ) ) {
+			$mpns               = array_map( 'trim', explode( ',', $request['mpn'] ) );
+			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Match the full MPN; the lookup column only stores its first 100 characters.
+				$args,
+				array(
+					'key'     => '_mpn',
+					'value'   => $mpns,
+					'compare' => 'IN',
+				)
+			);
+		}
+
 		// Filter by tax class.
 		if ( ! empty( $request['tax_class'] ) ) {
 			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to one parent's variations by post_parent, so this resolves through the postmeta post_id index.
@@ -1340,6 +1364,13 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			'validate_callback' => 'rest_validate_request_arg',
 		);

+		$params['mpn'] = array(
+			'description'       => __( 'Limit result set to product variations with specified MPN(s).', 'woocommerce' ),
+			'type'              => 'string',
+			'sanitize_callback' => 'sanitize_text_field',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+
 		$params['pos_products_only'] = array(
 			'description'       => __( 'Limit result set to variations visible in Point of Sale.', 'woocommerce' ),
 			'type'              => 'boolean',
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 077ed424283..9327a035c8e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -49,6 +49,16 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 	 */
 	private $search_sku_arg_value = '';

+	/**
+	 * The value of the 'search_mpn' argument if present.
+	 *
+	 * See prepare_objects_query()
+	 *
+	 * @var string
+	 * @since 11.4.0
+	 */
+	private $search_mpn_arg_value = '';
+
 	/**
 	 * If the 'search_name_or_sku' argument is present this will be set
 	 * to an array of the (space-separated) tokens that form the argument value.
@@ -370,7 +380,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 				'tokens' => $tokens,
 			);

-			unset( $request['search'], $request['search_sku'], $request['sku'], $request['search_name_or_sku'], $args['s'] );
+			unset( $request['search'], $request['search_sku'], $request['sku'], $request['search_name_or_sku'], $request['search_mpn'], $request['mpn'], $args['s'] );
 		}

 		$search_name_or_sku_arg = $request['search_name_or_sku'] ?? '';
@@ -412,6 +422,14 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			}
 		}

+		// Do a partial match for MPN. Supersedes the 'mpn' argument, that does exact matching.
+		if ( '' !== ( $request['search_mpn'] ?? '' ) ) {
+			// Store this for use in the query clause filters.
+			$this->search_mpn_arg_value = $request['search_mpn'];
+
+			unset( $request['mpn'] );
+		}
+
 		if ( ! empty( $request['global_unique_id'] ) ) {
 			$global_unique_ids  = array_map( 'trim', explode( ',', $request['global_unique_id'] ) );
 			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
@@ -424,6 +442,18 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			);
 		}

+		if ( '' !== ( $request['mpn'] ?? '' ) ) {
+			$mpns               = array_map( 'trim', explode( ',', $request['mpn'] ) );
+			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Match the full MPN; the lookup column only stores its first 100 characters.
+				$args,
+				array(
+					'key'     => '_mpn',
+					'value'   => $mpns,
+					'compare' => 'IN',
+				)
+			);
+		}
+
 		// Filter by tax class.
 		if ( ! empty( $request['tax_class'] ) ) {
 			$args['meta_query'] = $this->add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- wc_product_meta_lookup.tax_class is unindexed and holds NULL where _tax_class is unset, so it is neither faster nor equivalent.
@@ -476,7 +506,14 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 		}

 		// Force the post_type argument, since it's not a user input variable.
-		if ( ! empty( $request['sku'] ) || ! empty( $request['search_sku'] ) || $this->search_name_or_sku_tokens || $this->search_fields_tokens ) {
+		if (
+			! empty( $request['sku'] )
+			|| ! empty( $request['search_sku'] )
+			|| '' !== ( $request['mpn'] ?? '' )
+			|| '' !== ( $request['search_mpn'] ?? '' )
+			|| $this->search_name_or_sku_tokens
+			|| $this->search_fields_tokens
+		) {
 			$args['post_type'] = array( 'product', 'product_variation' );
 		} else {
 			$args['post_type'] = $this->post_type;
@@ -513,7 +550,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 	 * @return array
 	 */
 	protected function get_objects( $query_args ) {
-		$add_search_criteria = $this->search_sku_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;
+		$add_search_criteria = $this->search_sku_arg_value || '' !== $this->search_mpn_arg_value || $this->search_name_or_sku_tokens || $this->search_fields_tokens;

 		// Add filters for search criteria in product postmeta via the lookup table.
 		if ( $add_search_criteria ) {
@@ -534,6 +571,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			remove_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) );

 			$this->search_sku_arg_value      = '';
+			$this->search_mpn_arg_value      = '';
 			$this->search_name_or_sku_tokens = null;
 			$this->search_fields_tokens      = null;
 		}
@@ -569,6 +607,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 		// Only join if we need meta table search.
 		if ( ! $this->search_fields_tokens &&
 			! $this->search_sku_arg_value &&
+			'' === $this->search_mpn_arg_value &&
 			! ( $this->search_name_or_sku_tokens && wc_product_sku_enabled() ) ) {
 			return $join;
 		}
@@ -605,6 +644,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			$like_search = '%' . $wpdb->esc_like( $this->search_sku_arg_value ) . '%';
 			$where      .= ' AND ' . $wpdb->prepare( '(wc_product_meta_lookup.sku LIKE %s)', $like_search );
 		}
+
+		if ( '' !== $this->search_mpn_arg_value ) {
+			$like_search = '%' . $wpdb->esc_like( $this->search_mpn_arg_value ) . '%';
+			$where      .= ' AND ' . $wpdb->prepare( '(wc_product_meta_lookup.mpn LIKE %s)', $like_search );
+		}
 		return $where;
 	}

@@ -626,6 +670,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			'name'              => "{$wpdb->posts}.post_title",
 			'sku'               => 'wc_product_meta_lookup.sku',
 			'global_unique_id'  => 'wc_product_meta_lookup.global_unique_id',
+			'mpn'               => 'wc_product_meta_lookup.mpn',
 			'description'       => "{$wpdb->posts}.post_content",
 			'short_description' => "{$wpdb->posts}.post_excerpt",
 		);
@@ -855,6 +900,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			$product->set_global_unique_id( wc_clean( $request['global_unique_id'] ) );
 		}

+		// MPN.
+		if ( isset( $request['mpn'] ) ) {
+			$product->set_mpn( sanitize_text_field( $request['mpn'] ) );
+		}
+
 		// Attributes.
 		if ( isset( $request['attributes'] ) ) {
 			$attributes = array();
@@ -1290,6 +1340,11 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 					'type'        => 'string',
 					'context'     => array( 'view', 'edit' ),
 				),
+				'mpn'                   => array(
+					'description' => __( 'Manufacturer part number.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+				),
 				'price'                 => array(
 					'description' => __( 'Current product price.', 'woocommerce' ),
 					'type'        => 'string',
@@ -1913,6 +1968,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			'validate_callback' => 'rest_validate_request_arg',
 		);

+		$params['search_mpn'] = array(
+			'description'       => __( "Limit results to those with an MPN that partially matches a string. This argument takes precedence over 'mpn'.", 'woocommerce' ),
+			'type'              => 'string',
+			'sanitize_callback' => 'sanitize_text_field',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+
 		$params['search_name_or_sku'] = array(
 			'description'       => __( "Limit results to those with a name or SKU that partial matches a string. This argument takes precedence over 'search', 'sku' and 'search_sku'.", 'woocommerce' ),
 			'type'              => 'string',
@@ -1920,13 +1982,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			'validate_callback' => 'rest_validate_request_arg',
 		);

-		$search_fields_enum = array( 'name', 'global_unique_id', 'description', 'short_description' );
+		$search_fields_enum = array( 'name', 'global_unique_id', 'mpn', 'description', 'short_description' );
 		if ( wc_product_sku_enabled() ) {
 			$search_fields_enum[] = 'sku';
 		}

 		$params['search_fields'] = array(
-			'description'       => __( 'Limit search to specific fields when used with search parameter. Available fields: name, sku, global_unique_id, description, short_description. This argument takes precedence over all other search parameters.', 'woocommerce' ),
+			'description'       => __( 'Limit search to specific fields when used with search parameter. Available fields: name, sku, global_unique_id, mpn, description, short_description. This argument takes precedence over all other search parameters.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
 				'type' => 'string',
@@ -1995,6 +2057,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 			'validate_callback' => 'rest_validate_request_arg',
 		);

+		$params['mpn'] = array(
+			'description'       => __( 'Limit result set to products with specific MPN(s). Use commas to separate.', 'woocommerce' ),
+			'type'              => 'string',
+			'sanitize_callback' => 'sanitize_text_field',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+
 		$params['pos_products_only'] = array(
 			'description'       => __( 'Limit result set to products visible in Point of Sale.', 'woocommerce' ),
 			'type'              => 'boolean',
@@ -2103,6 +2172,10 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 				$data['global_unique_id'] = $product->get_global_unique_id( $context );
 			}

+			if ( in_array( 'mpn', $fields, true ) ) {
+				$data['mpn'] = $product->get_mpn( $context );
+			}
+
 			foreach ( CustomsDataValidator::FIELDS as $property ) {
 				if ( in_array( $property, $fields, true ) ) {
 					$data[ $property ] = $product->{ 'get_' . $property }( $context );
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index a910f5f5b60..891d1675208 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -2251,6 +2251,10 @@ function wc_update_product_lookup_tables() {
 		'tax_status', // When last column is updated, woocommerce_product_lookup_table_is_generating is updated.
 	);

+	if ( get_option( 'woocommerce_schema_version', 0 ) >= 1140 ) {
+		array_splice( $columns, 4, 0, array( 'mpn' ) );
+	}
+
 	foreach ( $columns as $index => $column ) {
 		if ( 'average_rating' === $column && ! $reviews_enabled ) {
 			continue;
@@ -2351,6 +2355,13 @@ function wc_update_product_lookup_tables_column( $column ) {
 				"
 			);
 			break;
+		case 'mpn':
+			$wpdb->query(
+				"UPDATE {$wpdb->wc_product_meta_lookup} lookup_table
+				LEFT JOIN {$wpdb->postmeta} meta ON lookup_table.product_id = meta.post_id AND meta.meta_key = '_mpn'
+				SET lookup_table.mpn = LEFT( COALESCE( meta.meta_value, '' ), 100 )"
+			);
+			break;
 		case 'sku':
 		case 'global_unique_id':
 		case 'stock_status':
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 9cf6434fbe0..cc134a141ff 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -4244,3 +4244,20 @@ function wc_update_1140_re_enable_autoload_options(): void {
 		)
 	);
 }
+
+/**
+ * Add the MPN lookup column and index for existing stores.
+ *
+ * @since 11.4.0
+ */
+function wc_update_1140_add_mpn_to_product_lookup_table(): void {
+	global $wpdb;
+
+	if ( ! $wpdb->get_var( "SHOW COLUMNS FROM {$wpdb->wc_product_meta_lookup} LIKE 'mpn'" ) ) {
+		$wpdb->query( "ALTER TABLE {$wpdb->wc_product_meta_lookup} ADD COLUMN mpn varchar(100) NOT NULL DEFAULT '' AFTER global_unique_id" );
+	}
+
+	if ( ! $wpdb->get_var( "SHOW INDEX FROM {$wpdb->wc_product_meta_lookup} WHERE Key_name = 'mpn'" ) ) {
+		$wpdb->query( "ALTER TABLE {$wpdb->wc_product_meta_lookup} ADD INDEX mpn (mpn(50))" );
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php
index 29f0a8bf189..f8ffba1e2d1 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/products.php
@@ -532,7 +532,7 @@ class Products_API_V2 extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Test product schema.
+	 * @testdox The product schema includes MPN.
 	 *
 	 * @since 3.0.0
 	 */
@@ -543,6 +543,7 @@ class Products_API_V2 extends WC_REST_Unit_Test_Case {
 		$response   = $this->server->dispatch( $request );
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];
-		$this->assertEquals( 65, count( $properties ) );
+		$this->assertEquals( 66, count( $properties ) );
+		$this->assertArrayHasKey( 'mpn', $properties );
 	}
 }
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
index 9723081bf16..434605bbebe 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
@@ -389,7 +389,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Test variation schema.
+	 * @testdox The variation schema includes MPN.
 	 *
 	 * @since 3.5.0
 	 */
@@ -401,7 +401,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertEquals( 45, count( $properties ) );
+		$this->assertEquals( 46, count( $properties ) );
 		$this->assertArrayHasKey( 'id', $properties );
 		$this->assertArrayHasKey( 'date_created', $properties );
 		$this->assertArrayHasKey( 'date_modified', $properties );
@@ -409,6 +409,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'permalink', $properties );
 		$this->assertArrayHasKey( 'sku', $properties );
 		$this->assertArrayHasKey( 'global_unique_id', $properties );
+		$this->assertArrayHasKey( 'mpn', $properties );
 		$this->assertArrayHasKey( 'price', $properties );
 		$this->assertArrayHasKey( 'regular_price', $properties );
 		$this->assertArrayHasKey( 'sale_price', $properties );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
index b6338eb04f1..91c33d348f4 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/products.php
@@ -646,7 +646,7 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Test product schema.
+	 * @testdox The product schema includes MPN.
 	 *
 	 * @since 3.5.0
 	 */
@@ -657,7 +657,8 @@ class WC_Tests_API_Product extends WC_REST_Unit_Test_Case {
 		$response   = $this->server->dispatch( $request );
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];
-		$this->assertEquals( 75, count( $properties ) );
+		$this->assertEquals( 76, count( $properties ) );
+		$this->assertArrayHasKey( 'mpn', $properties );
 		$this->assertArrayHasKey( 'customs_commodity_code', $properties );
 		$this->assertArrayHasKey( 'customs_country_of_origin', $properties );
 		$this->assertArrayHasKey( 'customs_description', $properties );
diff --git a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
index e7c1d84064b..41a8c81a8ec 100644
--- a/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/importers/class-wc-product-csv-importer-controller-test.php
@@ -23,6 +23,19 @@ class WC_Product_CSV_Importer_Controller_Test extends WC_Unit_Test_Case {
 		require_once $bootstrap->plugin_dir . '/includes/admin/importers/class-wc-product-csv-importer-controller.php';
 	}

+	/**
+	 * @testdox The CSV importer automatically maps the MPN column.
+	 */
+	public function test_mpn_csv_header_mapping(): void {
+		$sut    = new WC_Product_CSV_Importer_Controller();
+		$method = new ReflectionMethod( $sut, 'auto_map_columns' );
+		$method->setAccessible( true );
+
+		$mapping = $method->invoke( $sut, array( 'ID', 'MPN' ), false );
+
+		$this->assertSame( 'mpn', $mapping['MPN'] );
+	}
+
 	/**
 	 * Tests that the automatic mapping is case insensitive so that columns can be matched more easily.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
index 31329f6a6bf..4032850dd5d 100644
--- a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
@@ -157,6 +157,61 @@ class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox The classic editor saves MPNs, preserving omitted values and allowing explicit clearing.
+	 * @testWith [null, "ORIGINAL"]
+	 *           ["0", "0"]
+	 *           ["PART / Blue", "PART / Blue"]
+	 *           ["", ""]
+	 *
+	 * @param string|null $submitted_mpn Submitted MPN, or null to omit the input.
+	 * @param string      $expected_mpn Expected saved value.
+	 */
+	public function test_save_mpn( ?string $submitted_mpn, string $expected_mpn ): void {
+		$sut = new WC_Product_Simple();
+		$sut->set_mpn( 'ORIGINAL' );
+		$sut->save();
+
+		$_POST = array();
+		if ( null !== $submitted_mpn ) {
+			$_POST['_mpn'] = wp_slash( $submitted_mpn ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Supply Core's form input.
+		}
+
+		WC_Meta_Box_Product_Data::save( $sut->get_id(), get_post( $sut->get_id() ) );
+
+		$this->assertSame( $expected_mpn, wc_get_product( $sut->get_id() )->get_mpn( 'edit' ) );
+	}
+
+	/**
+	 * @testdox Variation saves preserve omitted MPNs and allow explicit clearing.
+	 * @testWith [null, "ORIGINAL"]
+	 *           ["0", "0"]
+	 *           ["PART / Blue", "PART / Blue"]
+	 *           ["", ""]
+	 *
+	 * @param string|null $submitted_mpn Submitted MPN, or null to omit the input.
+	 * @param string      $expected_mpn Expected saved value.
+	 */
+	public function test_save_variation_mpn( ?string $submitted_mpn, string $expected_mpn ): void {
+		$parent = new WC_Product_Variable();
+		$parent->save();
+		$sut = new WC_Product_Variation();
+		$sut->set_parent_id( $parent->get_id() );
+		$sut->set_mpn( 'ORIGINAL' );
+		$sut->save();
+
+		$_POST = array( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Supply the variation editor request.
+			'variable_post_id' => array( $sut->get_id() ),
+		);
+		if ( null !== $submitted_mpn ) {
+			$_POST['variable_mpn'] = array( wp_slash( $submitted_mpn ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Supply Core's form input.
+		}
+
+		WC_Meta_Box_Product_Data::save_variations( $parent->get_id(), get_post( $parent->get_id() ) );
+
+		$this->assertSame( $expected_mpn, wc_get_product( $sut->get_id() )->get_mpn( 'edit' ) );
+	}
+
 	/**
 	 * Save a product through the public classic product data meta-box seam.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 4a7adac5d07..8b245767fad 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -414,6 +414,22 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Stores already on 11.3 schedule the MPN schema migration when upgrading.
+	 */
+	public function test_mpn_migration_is_scheduled_after_113(): void {
+		$args = array( 'update_callback' => 'wc_update_1140_add_mpn_to_product_lookup_table' );
+		update_option( 'woocommerce_db_version', '11.3.0' );
+		$this->assertNull( WC()->queue()->get_next( 'woocommerce_run_update_callback', $args, 'woocommerce-db-updates' ) );
+
+		WC_Install::run_manual_database_update();
+
+		$this->assertNotNull(
+			WC()->queue()->get_next( 'woocommerce_run_update_callback', $args, 'woocommerce-db-updates' ),
+			'Stores already on 11.3 must receive the MPN schema migration.'
+		);
+	}
+
 	/**
 	 * Tests that the version check reaches the automatic database updater.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-mpn-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-mpn-test.php
new file mode 100644
index 00000000000..b7719dd61d9
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-mpn-test.php
@@ -0,0 +1,361 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Enums\ProductStatus;
+
+/**
+ * Integration tests for manufacturer part numbers.
+ */
+class WC_Product_Mpn_Test extends WC_Unit_Test_Case {
+	/**
+	 * Load migration functions, which are normally loaded only during updates.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		require_once WC_ABSPATH . 'includes/wc-update-functions.php';
+	}
+
+	/**
+	 * Reset the REST server created for endpoint tests.
+	 */
+	public function tearDown(): void {
+		$this->clear_rest_server();
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox MPNs survive product and variation saves, updates, and explicit clearing.
+	 * @testWith ["simple"]
+	 *           ["variable"]
+	 *           ["variation"]
+	 *
+	 * @param string $type Product type.
+	 */
+	public function test_mpn_persistence( string $type ): void {
+		$sut = $this->create_product( $type );
+		foreach ( array( 'AB-001 / Blue', '0', '' ) as $mpn ) {
+			$sut->set_mpn( $mpn );
+			$sut->save();
+			$sut = wc_get_product( $sut->get_id() );
+			$this->assertSame( $mpn, $sut->get_mpn( 'edit' ), 'Reloading must preserve the MPN, including zero and an explicit empty value.' );
+			$this->assertSame( $mpn, $this->get_lookup_mpn( $sut->get_id() ), 'The lookup value must follow CRUD updates.' );
+		}
+	}
+
+	/**
+	 * @testdox A variation can share an MPN with another product without inheriting its parent's MPN.
+	 */
+	public function test_mpn_is_not_unique_or_inherited(): void {
+		$sut    = $this->create_product( ProductType::VARIATION );
+		$parent = wc_get_product( $sut->get_parent_id() );
+		$parent->set_mpn( 'SHARED-PART' );
+		$parent->save();
+		$this->assertSame( '', wc_get_product( $sut->get_id() )->get_mpn(), 'An unspecified variation MPN must remain empty.' );
+
+		$sut->set_mpn( 'SHARED-PART' );
+		$sut->save();
+		$this->assertSame( 'SHARED-PART', wc_get_product( $sut->get_id() )->get_mpn() );
+	}
+
+	/**
+	 * @testdox Lookup regeneration restores truncated multibyte lookup values without changing the full MPN.
+	 */
+	public function test_mpn_lookup_regeneration(): void {
+		global $wpdb;
+
+		$sut = $this->create_product( ProductType::SIMPLE );
+		$mpn = str_repeat( '部', 101 );
+		$sut->set_mpn( $mpn );
+		$sut->save();
+		$empty = $this->create_product( ProductType::SIMPLE );
+
+		$this->assertSame( str_repeat( '部', 100 ), $this->get_lookup_mpn( $sut->get_id() ) );
+		$wpdb->update(
+			$wpdb->wc_product_meta_lookup,
+			array( 'mpn' => 'STALE' ),
+			array( 'product_id' => $sut->get_id() )
+		);
+
+		wc_update_product_lookup_tables_column( 'mpn' );
+
+		$this->assertSame( $mpn, wc_get_product( $sut->get_id() )->get_mpn(), 'The lookup limit must not truncate the source value.' );
+		$this->assertSame( str_repeat( '部', 100 ), $this->get_lookup_mpn( $sut->get_id() ) );
+		$this->assertSame( '', $this->get_lookup_mpn( $empty->get_id() ), 'Absent metadata must produce an empty lookup value, never NULL.' );
+	}
+
+	/**
+	 * @testdox The MPN schema upgrade leaves existing values untouched and subsequent saves populate the lookup.
+	 */
+	public function test_mpn_schema_upgrade_does_not_backfill_existing_values(): void {
+		$sut = $this->create_product( ProductType::SIMPLE );
+		update_option( 'woocommerce_schema_version', '920' );
+		$sut->set_mpn( 'PRE-UPGRADE' );
+		$sut->save();
+		$this->assertSame( 'PRE-UPGRADE', wc_get_product( $sut->get_id() )->get_mpn() );
+		$this->assertSame( '', $this->get_lookup_mpn( $sut->get_id() ) );
+
+		update_option( 'woocommerce_schema_version', '1140' );
+		wc_update_1140_add_mpn_to_product_lookup_table();
+		wc_update_1140_add_mpn_to_product_lookup_table();
+		$this->assertSame( 'PRE-UPGRADE', wc_get_product( $sut->get_id() )->get_mpn() );
+		$this->assertSame( '', $this->get_lookup_mpn( $sut->get_id() ), 'The schema upgrade must not backfill pre-existing MPN metadata.' );
+
+		$sut->set_mpn( 'POST-UPGRADE' );
+		$sut->save();
+		$this->assertSame( 'POST-UPGRADE', wc_get_product( $sut->get_id() )->get_mpn() );
+		$this->assertSame( 'POST-UPGRADE', $this->get_lookup_mpn( $sut->get_id() ) );
+	}
+
+	/**
+	 * @testdox REST product and variation endpoints persist MPNs and distinguish omission from explicit clearing.
+	 * @testWith ["v2", "simple"]
+	 *           ["v3", "simple"]
+	 *           ["v3", "variation"]
+	 *
+	 * @param string $version REST API version.
+	 * @param string $type Product type.
+	 */
+	public function test_mpn_rest_round_trip( string $version, string $type ): void {
+		$server = $this->create_product_rest_server();
+		$cases  = array(
+			'update'  => array( array( 'mpn' => 'PART-123' ), 'PART-123' ),
+			'zero'    => array( array( 'mpn' => '0' ), '0' ),
+			'omitted' => array( array(), 'ORIGINAL' ),
+			'clear'   => array( array( 'mpn' => '' ), '' ),
+		);
+
+		foreach ( $cases as $case => list( $params, $expected_mpn ) ) {
+			$sut = $this->create_product( $type );
+			$sut->set_mpn( 'ORIGINAL' );
+			$sut->save();
+
+			$route   = ProductType::VARIATION === $type ? "/wc/$version/products/{$sut->get_parent_id()}/variations/{$sut->get_id()}" : "/wc/$version/products/{$sut->get_id()}";
+			$request = new WP_REST_Request( 'PUT', $route );
+			$request->set_body_params( $params );
+			$this->assertSame( 200, $server->dispatch( $request )->get_status(), $case );
+			$response = $server->dispatch( new WP_REST_Request( 'GET', $route ) );
+			$this->assertSame( $expected_mpn, $response->get_data()['mpn'], $case );
+			$this->assertSame( $expected_mpn, wc_get_product( $sut->get_id() )->get_mpn( 'edit' ), $case );
+		}
+	}
+
+	/**
+	 * @testdox MPN search returns the expected products and variations.
+	 * @dataProvider mpn_search_provider
+	 *
+	 * @param array $params Search parameters.
+	 * @param array $expected_keys Keys of the expected search fixtures.
+	 */
+	public function test_mpn_rest_search( array $params, array $expected_keys ): void {
+		$server   = $this->create_product_rest_server();
+		$products = $this->create_search_products();
+		$expected = array_map(
+			static function ( $key ) use ( $products ) {
+				return $products[ $key ]->get_id();
+			},
+			$expected_keys
+		);
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+		$request->set_query_params( $params );
+		$response = $server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( $expected, wp_list_pluck( $response->get_data(), 'id' ) );
+	}
+
+	/**
+	 * Provide search parameters and expected fixture keys.
+	 *
+	 * @return array
+	 */
+	public static function mpn_search_provider(): array {
+		return array(
+			'exact MPN'                         => array( array( 'mpn' => 'PART_%_42' ), array( 'target' ) ),
+			'literal wildcard symbols'          => array( array( 'search_mpn' => '_%' ), array( 'target' ) ),
+			'partial MPN and name'              => array(
+				array(
+					'search_mpn' => '_%',
+					'search'     => 'target',
+				),
+				array( 'target' ),
+			),
+			'partial MPN and SKU'               => array(
+				array(
+					'search_mpn' => '_%',
+					'search_sku' => 'mpn-target',
+				),
+				array( 'target' ),
+			),
+			'MPN must also match SKU'           => array(
+				array(
+					'search_mpn' => 'missing',
+					'search_sku' => 'mpn-target',
+				),
+				array(),
+			),
+			'MPN must match name/SKU'           => array(
+				array(
+					'search_mpn'         => 'missing',
+					'search_name_or_sku' => 'target',
+				),
+				array(),
+			),
+			'partial supersedes exact'          => array(
+				array(
+					'search_mpn' => '_%',
+					'mpn'        => 'missing',
+				),
+				array( 'target' ),
+			),
+			'partial zero MPN'                  => array( array( 'search_mpn' => '0' ), array( 'variation' ) ),
+			'exact zero MPN'                    => array( array( 'mpn' => '0' ), array( 'variation' ) ),
+			'explicit search fields'            => array(
+				array(
+					'search'        => '_%',
+					'search_fields' => array( 'mpn' ),
+					'search_mpn'    => 'missing',
+				),
+				array( 'target' ),
+			),
+			'search fields supersede exact MPN' => array(
+				array(
+					'search'        => '_%',
+					'search_fields' => array( 'mpn' ),
+					'mpn'           => 'missing',
+				),
+				array( 'target' ),
+			),
+			'name search supersedes exact MPN'  => array(
+				array(
+					'search'        => 'other',
+					'search_fields' => array( 'name' ),
+					'mpn'           => 'PART_%_42',
+				),
+				array( 'other' ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox MPN search state does not affect a later request on the same REST server.
+	 * @testWith [{"search_mpn": "_%"}]
+	 *           [{"search": "_%", "search_fields": ["mpn"]}]
+	 *
+	 * @param array $params First request's search parameters.
+	 */
+	public function test_mpn_search_does_not_affect_later_requests( array $params ): void {
+		$server   = $this->create_product_rest_server();
+		$products = $this->create_search_products();
+		$request  = new WP_REST_Request( 'GET', '/wc/v3/products' );
+		$request->set_query_params( $params );
+		$response = $server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( $products['target']->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+
+		$request = new WP_REST_Request( 'GET', '/wc/v3/products' );
+		$request->set_param( 'include', array( $products['other']->get_id() ) );
+		$response = $server->dispatch( $request );
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( $products['other']->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+	}
+
+	/**
+	 * @testdox The variation endpoint accepts zero as an exact MPN filter.
+	 */
+	public function test_variation_mpn_rest_search(): void {
+		$server = $this->create_product_rest_server();
+		$sut    = $this->create_product( ProductType::VARIATION );
+		$sut->set_mpn( '0' );
+		$sut->save();
+
+		$request = new WP_REST_Request( 'GET', "/wc/v3/products/{$sut->get_parent_id()}/variations" );
+		$request->set_param( 'mpn', '0' );
+		$response = $server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( $sut->get_id() ), wp_list_pluck( $response->get_data(), 'id' ) );
+	}
+
+	/**
+	 * Read the lookup copy independently of the product getter.
+	 *
+	 * @param int $product_id Product ID.
+	 * @return string|null
+	 */
+	private function get_lookup_mpn( int $product_id ): ?string {
+		global $wpdb;
+
+		$row = $wpdb->get_row(
+			$wpdb->prepare(
+				"SELECT mpn FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d",
+				$product_id
+			)
+		);
+
+		return $row->mpn ?? null;
+	}
+
+	/**
+	 * Create products that distinguish literal MPN matches from wildcard matches.
+	 *
+	 * @return array<string, WC_Product>
+	 */
+	private function create_search_products(): array {
+		$target = $this->create_product( ProductType::SIMPLE );
+		$target->set_name( 'MPN target' );
+		$target->set_sku( 'mpn-target-sku' );
+		$target->set_mpn( 'PART_%_42' );
+		$target->save();
+		$other = $this->create_product( ProductType::SIMPLE );
+		$other->set_name( 'MPN target other' );
+		$other->set_mpn( 'PART-XX42' );
+		$other->save();
+		$variation = $this->create_product( ProductType::VARIATION );
+		$variation->set_mpn( '0' );
+		$variation->save();
+
+		return array(
+			'target'    => $target,
+			'other'     => $other,
+			'variation' => $variation,
+		);
+	}
+
+	/**
+	 * Create a product with only the fixtures needed by MPN tests.
+	 *
+	 * @param string $type Product type.
+	 * @return WC_Product
+	 */
+	private function create_product( string $type ): WC_Product {
+		$class   = WC_Product_Factory::get_product_classname( 0, $type );
+		$product = new $class();
+		$product->set_name( 'MPN test product' );
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->set_regular_price( '10' );
+		if ( ProductType::VARIATION === $type ) {
+			$product->set_parent_id( $this->create_product( ProductType::VARIABLE )->get_id() );
+		}
+		$product->save();
+		return $product;
+	}
+
+	/**
+	 * Register product routes and authenticate the test administrator.
+	 *
+	 * @return WP_REST_Server
+	 */
+	private function create_product_rest_server(): WP_REST_Server {
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+		return $this->create_rest_server_with_routes(
+			array(
+				array( new WC_REST_Products_V2_Controller(), 'register_routes' ),
+				array( new WC_REST_Products_Controller(), 'register_routes' ),
+				array( new WC_REST_Product_Variations_Controller(), 'register_routes' ),
+			),
+			true
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index 1f378fc45cc..a3b4beb9047 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -349,6 +349,69 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 		$this->assertEquals( $this->structured_data->prepare_gtin( '123.4e-5' ), '12345' );
 	}

+	/**
+	 * @testdox Product schema includes zero MPNs, omits empty values, and preserves extension filters.
+	 * @testWith ["0"]
+	 *           [""]
+	 *
+	 * @param string $mpn Manufacturer part number.
+	 */
+	public function test_mpn_structured_data( string $mpn ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_mpn( $mpn );
+		$sut = new WC_Structured_Data();
+		$sut->generate_product_data( $product );
+		$this->assertSame( '' === $mpn ? null : $mpn, $sut->get_data()[0]['mpn'] ?? null );
+
+		add_filter(
+			'woocommerce_structured_data_product',
+			static function ( $markup ) {
+				$markup['mpn'] = 'EXTENSION-MPN';
+				return $markup;
+			}
+		);
+		$sut = new WC_Structured_Data();
+		$sut->generate_product_data( $product );
+		$this->assertSame( 'EXTENSION-MPN', $sut->get_data()[0]['mpn'] );
+	}
+
+	/**
+	 * @testdox Product schema uses the selected variation's MPN without inheriting the parent's value.
+	 * @testWith ["CHILD-PART", true, "CHILD-PART"]
+	 *           ["0", true, "0"]
+	 *           ["", true, null]
+	 *           ["CHILD-PART", false, "PARENT-PART"]
+	 *
+	 * @param string      $variation_mpn Variation MPN.
+	 * @param bool        $selected Whether the request selects the variation.
+	 * @param string|null $expected_mpn Expected MPN, or null when it should be omitted.
+	 */
+	public function test_mpn_structured_data_for_selected_variation( string $variation_mpn, bool $selected, ?string $expected_mpn ): void {
+		$product = WC_Helper_Product::create_variation_product();
+		$product->set_mpn( 'PARENT-PART' );
+		$product->save();
+		$variation = wc_get_product( wc_get_product_id_by_sku( 'DUMMY SKU VARIABLE HUGE RED 0' ) );
+		$variation->set_mpn( $variation_mpn );
+		$variation->save();
+		WC_Product_Variable::sync( $product->get_id() );
+
+		if ( $selected ) {
+			$_GET['attribute_pa_size']   = 'huge';
+			$_GET['attribute_pa_colour'] = 'red';
+			$_GET['attribute_pa_number'] = '0';
+		}
+		$sut = new WC_Structured_Data();
+		$sut->generate_product_data( wc_get_product( $product->get_id() ) );
+		$data = $sut->get_data()[0];
+
+		$this->assertSame( $selected ? 'Offer' : 'AggregateOffer', $data['offers'][0]['@type'] );
+		if ( null === $expected_mpn ) {
+			$this->assertArrayNotHasKey( 'mpn', $data );
+		} else {
+			$this->assertSame( $expected_mpn, $data['mpn'] );
+		}
+	}
+
 	/**
 	 * Test simple product offer structured data includes offer-level price currency.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index 8b605b4b3d7..5029849640e 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -78,6 +78,67 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox CSV exports include the MPN header and saved product or variation value.
+	 * @testWith ["simple"]
+	 *           ["variation"]
+	 *
+	 * @param string $type Product type.
+	 */
+	public function test_mpn_csv_export( string $type ): void {
+		$class   = WC_Product_Factory::get_product_classname( 0, $type );
+		$product = new $class();
+		$product->set_name( 'MPN test product' );
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->set_regular_price( '10' );
+		if ( ProductType::VARIATION === $type ) {
+			$parent = new WC_Product_Variable();
+			$parent->save();
+			$product->set_parent_id( $parent->get_id() );
+		}
+		$product->set_mpn( 'PART-CSV' );
+		$product->save();
+
+		$csv  = $this->export_mpn_csv( $product );
+		$rows = array_map( 'str_getcsv', explode( "\n", trim( $csv ) ) );
+
+		$this->assertSame( array( 'ID', 'MPN' ), $rows[0] );
+		$this->assertSame( array( (string) $product->get_id(), 'PART-CSV' ), $rows[1] );
+	}
+
+	/**
+	 * Export the ID and MPN columns for a single product or variation.
+	 *
+	 * @param WC_Product $product Product to export.
+	 * @return string
+	 */
+	private function export_mpn_csv( WC_Product $product ): string {
+		$sut = new WC_Product_CSV_Exporter();
+		$sut->set_product_ids_to_export( array( $product->get_id() ) );
+		$sut->set_column_names(
+			array(
+				'id'  => 'ID',
+				'mpn' => 'MPN',
+			)
+		);
+		$sut->set_columns_to_export( array( 'id', 'mpn' ) );
+		add_filter(
+			'woocommerce_product_export_product_query_args',
+			static function ( $args ) use ( $product ) {
+				$args['type'] = $product->get_type();
+				return $args;
+			}
+		);
+		$sut->prepare_data_to_export();
+
+		$headers = new ReflectionMethod( $sut, 'export_column_headers' );
+		$headers->setAccessible( true );
+		$data = new ReflectionMethod( $sut, 'get_csv_data' );
+		$data->setAccessible( true );
+
+		return $headers->invoke( $sut ) . $data->invoke( $sut );
+	}
+
 	/**
 	 * Write the current page the way file generation does, so the completion percentage includes its rows.
 	 *
@@ -95,9 +156,10 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
 	 * @testdox variations should use draft status from parent product
 	 */
 	public function test_get_column_value_published() {
-		$product = WC_Helper_Product::create_variation_product();
+		$product = new WC_Product_Variable();
 		$product->set_status( ProductStatus::DRAFT );
-		$product->save();
+		// Create variations after setting the parent status so cached variations also see the draft parent.
+		$product = WC_Helper_Product::create_variation_product( $product );

 		$this->product_ids = array_merge( array( $product->get_id() ), $product->get_children( 'edit' ) );

diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index a05c2aec2b2..8ed928009ad 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -35,6 +35,58 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox CSV imports update MPNs and allow zero and explicit clearing.
+	 * @testWith ["simple", "PART-IMPORTED"]
+	 *           ["simple", "0"]
+	 *           ["simple", ""]
+	 *           ["variation", "PART-IMPORTED"]
+	 *           ["variation", "0"]
+	 *           ["variation", ""]
+	 *
+	 * @param string $type Product type.
+	 * @param string $mpn MPN to import.
+	 */
+	public function test_mpn_csv_import( string $type, string $mpn ): void {
+		$class   = WC_Product_Factory::get_product_classname( 0, $type );
+		$product = new $class();
+		$product->set_name( 'MPN test product' );
+		$product->set_status( ProductStatus::PUBLISH );
+		$product->set_regular_price( '10' );
+		if ( ProductType::VARIATION === $type ) {
+			$parent = new WC_Product_Variable();
+			$parent->save();
+			$product->set_parent_id( $parent->get_id() );
+		}
+		$product->set_mpn( 'ORIGINAL' );
+		$product->save();
+		$csv = "ID,MPN\n{$product->get_id()},{$mpn}\n";
+
+		$temporary_file = wp_tempnam( 'mpn' );
+		$file           = $temporary_file . '.csv';
+		rename( $temporary_file, $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- The importer requires a CSV extension on the temporary fixture.
+		try {
+			file_put_contents( $file, $csv ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Write an isolated temporary CSV fixture.
+			$sut    = new WC_Product_CSV_Importer(
+				$file,
+				array(
+					'mapping'         => array(
+						'ID'  => 'id',
+						'MPN' => 'mpn',
+					),
+					'update_existing' => true,
+					'parse'           => true,
+				)
+			);
+			$result = $sut->import();
+
+			$this->assertSame( array( $product->get_id() ), $result['updated'], wp_json_encode( $result ) );
+			$this->assertSame( $mpn, wc_get_product( $product->get_id() )->get_mpn() );
+		} finally {
+			wp_delete_file( $file );
+		}
+	}
+
 	/**
 	 * @testdox variations need to set the status back to published if parent product is a draft
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index 1a6b6f089a4..06f2dda6731 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -81,6 +81,7 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
 			'description',
 			'short_description',
 			'sku',
+			'mpn',
 			'price',
 			'regular_price',
 			'sale_price',
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index 8eacee85f2d..5b918ab8ce2 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -175,6 +175,7 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {
 			'description',
 			'short_description',
 			'sku',
+			'mpn',
 			'global_unique_id',
 			'price',
 			'regular_price',