diff --git a/commerce_product_variation_csv.info.yml b/commerce_product_variation_csv.info.yml
new file mode 100644
index 0000000..73ecfc1
--- /dev/null
+++ b/commerce_product_variation_csv.info.yml
@@ -0,0 +1,7 @@
+name: Commerce Product Variation CSV
+description: Allows the export and import of variations via CSV.
+type: module
+core: 8.x
+dependencies:
+  - drupal:file
+  - commerce:commerce_product
diff --git a/src/CsvFileObject.php b/src/CsvFileObject.php
new file mode 100644
index 0000000..7b41af2
--- /dev/null
+++ b/src/CsvFileObject.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\commerce_product_variation_csv;
+
+/**
+ * Defines a wrapper around CSV data in a file.
+ *
+ * Extends SPLFileObject to:
+ * - Skip header rows on rewind.
+ * - Address columns by header name instead of numeric index.
+ * - Support mapping header names to custom keys.
+ */
+class CsvFileObject extends \SplFileObject {
+
+  /**
+   * Whether the file has a header row.
+   *
+   * @var bool
+   */
+  protected $hasHeader = FALSE;
+
+  /**
+   * The human-readable column headers, keyed by column index in the CSV.
+   *
+   * @var string[]
+   */
+  protected $headerMapping = [];
+
+  /**
+   * The loaded header.
+   *
+   * @var string[]
+   */
+  protected $header = [];
+
+  /**
+   * Constructs a new CsvFileObject object.
+   *
+   * @param string $file_name
+   *   The filename.
+   * @param bool $has_header
+   *   Whether the loaded file has a header row.
+   * @param array $header_mapping
+   *   The header mapping (real_column => mapped_column).
+   */
+  public function __construct($file_name, $has_header = FALSE, array $header_mapping = []) {
+    parent::__construct($file_name);
+
+    $this->setFlags(self::READ_CSV | self::READ_AHEAD | self::DROP_NEW_LINE | self::SKIP_EMPTY);
+    $this->hasHeader = $has_header;
+    $this->headerMapping = $header_mapping;
+    if ($this->hasHeader) {
+      $this->seek(0);
+      $this->header = $this->current();
+      $this->seek(1);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rewind() {
+    $index = $this->hasHeader ? 1 : 0;
+    $this->seek($index);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function current() {
+    $row = parent::current();
+    if (!$row) {
+      // Invalid row, stop here.
+      return $row;
+    }
+    if ($this->hasHeader && $this->key() === 0) {
+      // Only data rows can be remapped.
+      return $row;
+    }
+
+    $remapped_row = [];
+    foreach ($row as $key => $value) {
+      $new_key = $key;
+      // Use the column name from the header as the default key.
+      if ($this->hasHeader) {
+        $new_key = trim($this->header[$key]);
+      }
+      // Map the selected key to the desired one, if any.
+      if (isset($this->headerMapping[$new_key])) {
+        $new_key = $this->headerMapping[$new_key];
+      }
+      $remapped_row[$new_key] = $value;
+    }
+
+    return $remapped_row;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function count() {
+    $count = iterator_count($this);
+    // The iterator_count() sets the pointer to the last element.
+    $this->rewind();
+
+    return $count;
+  }
+
+}
diff --git a/tests/fixtures/variations_generated_titles.csv b/tests/fixtures/variations_generated_titles.csv
new file mode 100644
index 0000000..676a1da
--- /dev/null
+++ b/tests/fixtures/variations_generated_titles.csv
@@ -0,0 +1,3 @@
+sku,status,list_price__number,list_price__currency_code,price__number,price__currency_code
+SKU1234,1,,,12.00,USD
+SKU5678,1,,,12.10,USD
diff --git a/tests/fixtures/variations_with_titles.csv b/tests/fixtures/variations_with_titles.csv
new file mode 100644
index 0000000..33e695d
--- /dev/null
+++ b/tests/fixtures/variations_with_titles.csv
@@ -0,0 +1,3 @@
+sku,status,title,list_price__number,list_price__currency_code,price__number,price__currency_code
+SKU1234,1,My Product 1234,,,12.00,USD
+SKU5678,1,My Product 5678,,,12.10,USD
diff --git a/tests/src/Unit/CsvFileObjectTest.php b/tests/src/Unit/CsvFileObjectTest.php
new file mode 100644
index 0000000..8c745ca
--- /dev/null
+++ b/tests/src/Unit/CsvFileObjectTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\commerce_product_variation_csv\Unit;
+
+use Drupal\commerce_product_variation_csv\CsvFileObject;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @group commerce_product_variation_csv
+ */
+class CsvFileObjectTest extends UnitTestCase {
+
+  public function testCsvFile(): void {
+    $csv = new CsvFileObject(__DIR__ . '/../../fixtures/variations_generated_titles.csv', TRUE);
+    self::assertEquals(2, $csv->count());
+
+    $current = $csv->current();
+    self::assertEquals([
+      'sku' => 'SKU1234',
+      'status' => '1',
+      'list_price__number' => '',
+      'list_price__currency_code' => '',
+      'price__number' => '12.00',
+      'price__currency_code' => 'USD',
+    ], $current);
+
+    $csv = new CsvFileObject(__DIR__ . '/../../fixtures/variations_with_titles.csv', TRUE);
+    self::assertEquals(2, $csv->count());
+
+    $current = $csv->current();
+    self::assertEquals([
+      'sku' => 'SKU1234',
+      'status' => '1',
+      'title' => 'My Product 1234',
+      'list_price__number' => '',
+      'list_price__currency_code' => '',
+      'price__number' => '12.00',
+      'price__currency_code' => 'USD',
+    ], $current);
+  }
+
+}
