diff --git a/core/lib/Drupal/Core/Cache/MemoryCounterBackendFactory.php b/core/lib/Drupal/Core/Cache/MemoryCounterBackendFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..02d0197ea7c5a49c4a6dcec479227a37371b3ded
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/MemoryCounterBackendFactory.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+class MemoryCounterBackendFactory implements CacheFactoryInterface {
+
+  /**
+   * Instantiated memory cache bins.
+   *
+   * @var \Drupal\Core\Cache\MemoryBackend[]
+   */
+  protected $bins = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get($bin) {
+    if (!isset($this->bins[$bin])) {
+      $this->bins[$bin] = new MemoryCounterBackend();
+    }
+    return $this->bins[$bin];
+  }
+
+}
diff --git a/core/modules/migrate/migrate.post_update.php b/core/modules/migrate/migrate.post_update.php
new file mode 100644
index 0000000000000000000000000000000000000000..9d3342f3686ff8b8cb496d0fab4de20422a780e4
--- /dev/null
+++ b/core/modules/migrate/migrate.post_update.php
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * @file
+ * Post update functions for migrate.
+ */
+
+/**
+ * Clear the source count cache.
+ */
+function migrate_post_update_clear_migrate_source_count_cache() {
+  // Empty post_update hook.
+}
diff --git a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
index da4d3b4ec7a81218546ddda6dccea8b49be18dd6..3825a01ce119cfed99173d6af07c509375664c2c 100644
--- a/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
+++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\migrate\Plugin\migrate\source;
 
+use Drupal\Component\Serialization\Json;
 use Drupal\Core\Plugin\PluginBase;
 use Drupal\migrate\Event\MigrateRollbackEvent;
 use Drupal\migrate\Event\RollbackAwareInterface;
@@ -246,7 +247,9 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
         $this->$property = (bool) $configuration[$config_key];
       }
     }
-    $this->cacheKey = !empty($configuration['cache_key']) ? $configuration['cache_key'] : NULL;
+    if ($this->cacheCounts) {
+      $this->cacheKey = $configuration['cache_key'] ?? $plugin_id . '-' . hash('sha256', Json::encode($configuration));
+    }
     $this->idMap = $this->migration->getIdMap();
     $this->highWaterProperty = !empty($configuration['high_water_property']) ? $configuration['high_water_property'] : FALSE;
 
@@ -265,7 +268,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
    * Initializes the iterator with the source data.
    *
    * @return \Iterator
-   *   Returns an iteratable object of data for this source.
+   *   Returns an iterable object of data for this source.
    */
   abstract protected function initializeIterator();
 
@@ -486,30 +489,19 @@ public function count($refresh = FALSE) {
       return -1;
     }
 
-    if (!isset($this->cacheKey)) {
-      $this->cacheKey = hash('sha256', $this->getPluginId());
-    }
-
-    // If a refresh is requested, or we're not caching counts, ask the derived
-    // class to get the count from the source.
-    if ($refresh || !$this->cacheCounts) {
-      $count = $this->doCount();
-      $this->getCache()->set($this->cacheKey, $count);
-    }
-    else {
-      // Caching is in play, first try to retrieve a cached count.
+    // Return the cached count if we are caching counts and a refresh is not
+    // requested.
+    if ($this->cacheCounts && !$refresh) {
       $cache_object = $this->getCache()->get($this->cacheKey, 'cache');
       if (is_object($cache_object)) {
-        // Success.
-        $count = $cache_object->data;
-      }
-      else {
-        // No cached count, ask the derived class to count 'em up, and cache
-        // the result.
-        $count = $this->doCount();
-        $this->getCache()->set($this->cacheKey, $count);
+        return $cache_object->data;
       }
     }
+    $count = $this->doCount();
+    // Update the cache if we are caching counts.
+    if ($this->cacheCounts) {
+      $this->getCache()->set($this->cacheKey, $count);
+    }
     return $count;
   }
 
diff --git a/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php
index a24b0b2b43c99acd0780f550ca665267ce8ffac4..954f8f16b74b7b0a54fbbcd5fdfbcdc5061a2955 100644
--- a/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php
+++ b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php
@@ -383,9 +383,9 @@ protected function fetchNextBatch() {
   abstract public function query();
 
   /**
-   * {@inheritdoc}
+   * Gets the source count using countQuery().
    */
-  public function count($refresh = FALSE) {
+  protected function doCount() {
     return (int) $this->query()->countQuery()->execute()->fetchField();
   }
 
diff --git a/core/modules/migrate/tests/modules/migrate_cache_counts_test/migrate_cache_counts_test.info.yml b/core/modules/migrate/tests/modules/migrate_cache_counts_test/migrate_cache_counts_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..60ab909f8044ca728c206a2136d6ac13733f3e3c
--- /dev/null
+++ b/core/modules/migrate/tests/modules/migrate_cache_counts_test/migrate_cache_counts_test.info.yml
@@ -0,0 +1,5 @@
+name: Cacheable Embedded Data Test
+type: module
+description: Module containing a cacheable embedded data source.
+package: Testing
+version: VERSION
diff --git a/core/modules/migrate/tests/modules/migrate_cache_counts_test/src/Plugin/migrate/source/CacheableEmbeddedDataSource.php b/core/modules/migrate/tests/modules/migrate_cache_counts_test/src/Plugin/migrate/source/CacheableEmbeddedDataSource.php
new file mode 100644
index 0000000000000000000000000000000000000000..510e776000a8284e19cb9c84a0f002b321625fe5
--- /dev/null
+++ b/core/modules/migrate/tests/modules/migrate_cache_counts_test/src/Plugin/migrate/source/CacheableEmbeddedDataSource.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\migrate_cache_counts_test\Plugin\migrate\source;
+
+use Drupal\migrate\Plugin\migrate\source\EmbeddedDataSource;
+use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
+
+/**
+ * A copy of embedded_data which allows caching the count.
+ *
+ * @MigrateSource(
+ *   id = "cacheable_embedded_data",
+ *   source_module = "migrate"
+ * )
+ */
+class CacheableEmbeddedDataSource extends EmbeddedDataSource {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function count($refresh = FALSE) {
+    return SourcePluginBase::count($refresh);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function doCount() {
+    return parent::count(TRUE);
+  }
+
+}
diff --git a/core/modules/migrate/tests/modules/migrate_sql_count_cache_test/migrate_sql_count_cache_test.info.yml b/core/modules/migrate/tests/modules/migrate_sql_count_cache_test/migrate_sql_count_cache_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..92c7af44da0d823c4c9051f81a2cd0c71b3f738d
--- /dev/null
+++ b/core/modules/migrate/tests/modules/migrate_sql_count_cache_test/migrate_sql_count_cache_test.info.yml
@@ -0,0 +1,4 @@
+type: module
+name: Migrate SqlBase count cache test
+description: Provides a source plugin to test that counts are cached in SQL sources.
+package: Testing
diff --git a/core/modules/migrate/tests/modules/migrate_sql_count_cache_test/src/Plugin/migrate/source/SqlCountCache.php b/core/modules/migrate/tests/modules/migrate_sql_count_cache_test/src/Plugin/migrate/source/SqlCountCache.php
new file mode 100644
index 0000000000000000000000000000000000000000..48026d10a64f34d936a65a149340f25fd5d26b50
--- /dev/null
+++ b/core/modules/migrate/tests/modules/migrate_sql_count_cache_test/src/Plugin/migrate/source/SqlCountCache.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\migrate_sql_count_cache_test\Plugin\migrate\source;
+
+use Drupal\migrate\Plugin\migrate\source\SqlBase;
+
+/**
+ * Source plugin for Sql count cache test.
+ *
+ * @MigrateSource(
+ *   id = "sql_count_cache"
+ * )
+ */
+class SqlCountCache extends SqlBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function fields() {
+    return [
+      'id' => t('Id'),
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIds() {
+    return [
+      'id' => [
+        'type' => 'integer',
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {
+    return $this->select('source_table', 's')->fields('s', ['id']);
+  }
+
+}
diff --git a/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
index d7058b1a7d26e29ad89309b969bcd8bede0f2e04..bbb4168badcb0a08eac2bbfb49a32233949ab6ba 100644
--- a/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
+++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
@@ -2,13 +2,23 @@
 
 namespace Drupal\Tests\migrate\Kernel;
 
+use Drupal\Core\Cache\MemoryCounterBackendFactory;
 use Drupal\Core\Database\Driver\sqlite\Connection;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
 
 /**
  * Base class for tests of Migrate source plugins that use a database.
  */
 abstract class MigrateSqlSourceTestBase extends MigrateSourceTestBase {
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+    $container->register('cache_factory', MemoryCounterBackendFactory::class);
+  }
+
   /**
    * Builds an in-memory SQLite database from a set of source data.
    *
@@ -67,12 +77,14 @@ protected function getDatabase(array $source_data) {
    *   (optional) Configuration for the source plugin.
    * @param mixed $high_water
    *   (optional) The value of the high water field.
+   * @param string|null $expected_cache_key
+   *   (optional) The expected cache key.
    *
    * @dataProvider providerSource
    *
    * @requires extension pdo_sqlite
    */
-  public function testSource(array $source_data, array $expected_data, $expected_count = NULL, array $configuration = [], $high_water = NULL) {
+  public function testSource(array $source_data, array $expected_data, $expected_count = NULL, array $configuration = [], $high_water = NULL, $expected_cache_key = NULL) {
     $plugin = $this->getPlugin($configuration);
 
     // Since we don't yet inject the database connection, we need to use a
@@ -82,6 +94,33 @@ public function testSource(array $source_data, array $expected_data, $expected_c
     $property->setAccessible(TRUE);
     $property->setValue($plugin, $this->getDatabase($source_data));
 
+    /** @var MemoryCounterBackend $cache **/
+    $cache = \Drupal::cache('migrate');
+    if ($expected_cache_key) {
+      // Verify the the computed cache key.
+      $property = $reflector->getProperty('cacheKey');
+      $property->setAccessible(TRUE);
+      $this->assertSame($expected_cache_key, $property->getValue($plugin));
+
+      // Cache miss prior to calling ::count().
+      $this->assertFalse($cache->get($expected_cache_key, 'cache'));
+
+      $this->assertSame([], $cache->getCounter('set'));
+      $count = $plugin->count();
+      $this->assertSame($expected_count, $count);
+      $this->assertSame([$expected_cache_key => 1], $cache->getCounter('set'));
+
+      // Cache hit afterwards.
+      $cache_item = $cache->get($expected_cache_key, 'cache');
+      $this->assertNotSame(FALSE, $cache_item, 'This is not a cache hit.');
+      $this->assertSame($expected_count, $cache_item->data);
+    }
+    else {
+      $this->assertSame([], $cache->getCounter('set'));
+      $plugin->count();
+      $this->assertSame([], $cache->getCounter('set'));
+    }
+
     parent::testSource($source_data, $expected_data, $expected_count, $configuration, $high_water);
   }
 
diff --git a/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5b4db34aa85121fe058af6188469d3989b05cc7
--- /dev/null
+++ b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\migrate\Kernel\Plugin\source;
+
+use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
+
+/**
+ * Tests SqlBase source count caching.
+ *
+ * @covers \Drupal\migrate_sql_count_cache_test\Plugin\migrate\source\SqlCountCache
+ * @covers \Drupal\migrate\Plugin\migrate\source\SqlBase::doCount
+ * @covers \Drupal\migrate\Plugin\migrate\source\SourcePluginBase::count
+ *
+ * @group migrate
+ */
+class MigrateSqlSourceCountCacheTest extends MigrateSqlSourceTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['migrate_sql_count_cache_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function providerSource() {
+    // All tests use the same source_data, expected_data, expected_count, and
+    // high_water. The high water is set later to maintain the order of the
+    // parameters.
+    $data = [
+      'source_data' => [
+        'source_table' => [
+          ['id' => 1],
+          ['id' => 2],
+          ['id' => 3],
+          ['id' => 4],
+        ],
+      ],
+      'expected_data' => [
+        ['id' => 1],
+        ['id' => 2],
+        ['id' => 3],
+        ['id' => 4],
+      ],
+      'expected_count' => 4,
+    ];
+
+    return [
+      'uncached source count' => $data,
+      'cached source count, auto-generated cache key' => $data + [
+        'configuration' => [
+          'cache_counts' => TRUE,
+        ],
+        'high_water' => NULL,
+        'expected_cache_key' => 'sql_count_cache-dbed2396c230e025663091479993a206441bf1f9ae4e60ebf3b504e4a76ad471',
+      ],
+      'cached source count, auto-generated cache key for alternative source configuration' => $data + [
+        'configuration' => [
+          'cache_counts' => TRUE,
+          'some_source_plugin_configuration_key' => 19920106,
+        ],
+        'high_water' => NULL,
+        'expected_cache_key' => 'sql_count_cache-83c62856dd5afc011f32574bcdc11c595557d629e1d73045e9353df2441ec269',
+      ],
+      'cached source count, provided cache key' => $data + [
+        'configuration' => [
+          'cache_counts' => TRUE,
+          'cache_key' => 'custom_cache_key_here',
+        ],
+        'high_water' => NULL,
+        'expected_cache_key' => 'custom_cache_key_here',
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrationSourceCacheTest.php b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrationSourceCacheTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a0cd3acfe162334f2a636eeb8fba18f42b895027
--- /dev/null
+++ b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrationSourceCacheTest.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\Tests\migrate\Kernel\Plugin\source;
+
+use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
+
+/**
+ * Test source counts are correctly cached.
+ *
+ * @group migrate
+ */
+class MigrationSourceCacheTest extends MigrateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['migrate_cache_counts_test'];
+
+  /**
+   * The migration plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->migrationPluginManager = $this->container->get('plugin.manager.migration');
+  }
+
+  /**
+   * Tests that counts for the same plugin_id are not crossed.
+   */
+  public function testCacheCountsNotContaminated() {
+    $migration_1_definition = [
+      'source' => [
+        'plugin' => 'cacheable_embedded_data',
+        'cache_counts' => TRUE,
+        'ids' => [
+          'id' => [
+            'type' => 'integer',
+          ],
+        ],
+        'data_rows' => [
+          [
+            ['id' => 1],
+          ],
+        ],
+      ],
+    ];
+    $migration_2_definition = [
+      'source' => [
+        'plugin' => 'cacheable_embedded_data',
+        'cache_counts' => TRUE,
+        'ids' => [
+          'id' => [
+            'type' => 'integer',
+          ],
+        ],
+        'data_rows' => [
+          ['id' => 1],
+          ['id' => 2],
+        ],
+      ],
+    ];
+
+    $migration_1 = $this->migrationPluginManager->createStubMigration($migration_1_definition);
+    $migration_2 = $this->migrationPluginManager->createStubMigration($migration_2_definition);
+    $migration_1_source = $migration_1->getSourcePlugin();
+    $migration_2_source = $migration_2->getSourcePlugin();
+
+    // Verify correct counts when count is refreshed.
+    $this->assertSame(1, $migration_1_source->count(TRUE));
+    $this->assertSame(2, $migration_2_source->count(TRUE));
+
+    // Verify correct counts are cached.
+    $this->assertSame(1, $migration_1_source->count());
+    $this->assertSame(2, $migration_2_source->count());
+
+    // Verify the cache keys are different.
+    $cache_key_property = new \ReflectionProperty(SourcePluginBase::class, 'cacheKey');
+    $cache_key_property->setAccessible(TRUE);
+    $this->assertNotEquals($cache_key_property->getValue($migration_1_source), $cache_key_property->getValue($migration_2_source));
+  }
+
+  /**
+   * Test that values are pulled from the cache when appropriate.
+   */
+  public function testCacheCountsUsed() {
+    $migration_definition = [
+      'source' => [
+        'plugin' => 'cacheable_embedded_data',
+        'cache_counts' => TRUE,
+        'ids' => [
+          'id' => [
+            'type' => 'integer',
+          ],
+        ],
+        'data_rows' => [
+          ['id' => 1],
+          ['id' => 2],
+        ],
+      ],
+    ];
+    $migration = $this->migrationPluginManager->createStubMigration($migration_definition);
+    $migration_source = $migration->getSourcePlugin();
+    $this->assertSame(2, $migration_source->count());
+
+    // Pollute the cache.
+    $cache_key_property = new \ReflectionProperty($migration_source, 'cacheKey');
+    $cache_key_property->setAccessible(TRUE);
+    $cache_key = $cache_key_property->getValue($migration_source);
+    \Drupal::cache('migrate')->set($cache_key, 7);
+    $this->assertSame(7, $migration_source->count());
+    $this->assertSame(2, $migration_source->count(TRUE));
+  }
+
+}