diff --git a/migrate_tools.module b/migrate_tools.module
index 98b5a51d1e18a2f83262aca41d5e697d1bb98b35..976891fa65d81459e1befb72e37a1468a2abc302 100644
--- a/migrate_tools.module
+++ b/migrate_tools.module
@@ -96,6 +96,6 @@ function migrate_tools_migrate_prepare_row(Row $row, MigrateSourceInterface $sou
     // Keep track of all source rows here, as SourcePluginBase::next() might
     // skip some rows, and we need them all to detect missing items in source to
     // delete in destination.
-    $migrateTools->addToSyncSourceIds($migration->getPluginId(), $row->getSourceIdValues());
+    $migrateTools->addToSyncSourceIds($migration->getPluginId(), $row->getSourceIdValues(), $source);
   }
 }
diff --git a/src/MigrateTools.php b/src/MigrateTools.php
index 5de7c6f4955f8137ba3bb08f8f26d85d69ea743a..529db5b8c7a7119d39616dd90431be67f0bc57d6 100644
--- a/src/MigrateTools.php
+++ b/src/MigrateTools.php
@@ -6,6 +6,7 @@ namespace Drupal\migrate_tools;
 
 use Drupal\Core\Database\Connection;
 use Drupal\migrate\Plugin\MigrateIdMapInterface;
+use Drupal\migrate\Plugin\MigrateSourceInterface;
 use Drupal\migrate\Plugin\MigrationInterface;
 
 /**
@@ -94,11 +95,13 @@ class MigrateTools {
    *   Migration ID.
    * @param array $sourceIds
    *   A set of SyncSourceIds. Gets serialized to retain its structure.
+   * @param \Drupal\migrate\Plugin\MigrateSourceInterface $source
+   *   The migrate source.
    *
    * @throws \Exception
    */
-  public function addToSyncSourceIds(string $migrationId, array $sourceIds): void
-  {
+  public function addToSyncSourceIds(string $migrationId, array $sourceIds, MigrateSourceInterface $source): void {
+    $sourceIds = $this->prepareSourceIdValues($sourceIds, $source);
     $this->bufferedSyncIdsEntries[] = [
       'migration_id' => $migrationId,
       // Serialize source IDs before saving them to retain their structure.
@@ -109,6 +112,37 @@ class MigrateTools {
     }
   }
 
+  /**
+   * Ensure all source IDs match the expected type.
+   *
+   * @param array $rowSourceIds
+   *   The source IDs from the current row.
+   * @param \Drupal\migrate\Plugin\MigrateSourceInterface $source
+   *   The migrate source.
+   *
+   * @return array
+   *   The updated source values.
+   *
+   * @see migrate_tools_migrate_prepare_row()
+   * @see https://www.drupal.org/node/3104268
+   */
+  protected function prepareSourceIdValues(array $rowSourceIds, MigrateSourceInterface $source): array {
+    $sourceIds = $source->getIds();
+    foreach ($rowSourceIds as $key => $value) {
+      if (!array_key_exists($key, $sourceIds)) {
+        continue;
+      }
+
+      // Cast the source ID as the configured type to avoid rollback
+      // and recreations when an exact match is not found.
+      if (in_array($sourceIds[$key]['type'], ['string', 'integer'], TRUE)) {
+        $rowSourceIds[$key] = $sourceIds[$key]['type'] === 'string' ? (string) $rowSourceIds[$key] : (int) $rowSourceIds[$key];
+      }
+    }
+
+    return $rowSourceIds;
+  }
+
   /**
    * Flushes any pending SyncSourceIds to the database.
    *
diff --git a/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.mixed_terms.yml b/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.mixed_terms.yml
new file mode 100644
index 0000000000000000000000000000000000000000..549a707e48b9d04de996c1b0c155f5736c8bc93c
--- /dev/null
+++ b/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.mixed_terms.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+id: mixed_terms
+label: Mixed Terms
+class: null
+field_plugin_method: null
+cck_plugin_method: null
+migration_tags: []
+migration_group: default
+source:
+  plugin: embedded_data
+  data_rows:
+    -
+      name: 1
+    -
+      name: '2'
+    -
+      name: Orange
+    -
+      name: 4.1
+  ids:
+    name:
+      type: string
+  constants:
+    vocabulary: fruit
+process:
+  name: name
+  vid: constants/vocabulary
+destination:
+  plugin: entity:taxonomy_term
+migration_dependencies:
+  required: []
+  optional: []
+dependencies:
+  enforced:
+    module:
+      - migrate_tools_test
diff --git a/tests/src/Functional/DrushCommandsTest.php b/tests/src/Functional/DrushCommandsTest.php
index db429de37e7d3dc77eb590600165cb1c3dfdf1d3..1ca8a3bedf746860a829f259d923ca638e87bfa9 100644
--- a/tests/src/Functional/DrushCommandsTest.php
+++ b/tests/src/Functional/DrushCommandsTest.php
@@ -104,6 +104,16 @@ final class DrushCommandsTest extends BrowserTestBase {
         'message_count' => 0,
         'last_imported' => '',
       ],
+      [
+        'group' => 'Default (default)',
+        'id' => 'mixed_terms',
+        'imported' => 0,
+        'status' => 'Idle',
+        'total' => 4,
+        'unprocessed' => 4,
+        'message_count' => 0,
+        'last_imported' => '',
+      ],
       [
         'group' => 'Default (default)',
         'id' => 'source_exception',
@@ -222,4 +232,32 @@ EOT;
     $this->assertCount(3, $id_map);
   }
 
+  /**
+   * Tests synced import with and without update enforced.
+   */
+  public function testSyncMixedKeysImport(): void {
+    $this->drush('mim', ['mixed_terms']);
+    $this->assertStringContainsString('1/4', $this->getErrorOutput());
+    $this->assertStringContainsString('4/4', $this->getErrorOutput());
+    $this->assertMatchesRegularExpression('/4\/4[^\n]+\[notice\][^\n]+Processed 4 items \(4 created, 0 updated, 0 failed, 0 ignored\) - done with \'mixed_terms\'/', $this->getErrorOutput());
+    $term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load(1);
+    $this->assertEquals('1', $term->label());
+    $term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load(2);
+    $this->assertEquals('2', $term->label());
+    $source = $this->container->get('config.factory')->getEditable('migrate_plus.migration.mixed_terms')->get('source');
+    $source['data_rows'][] = ['name' => 'Grape'];
+    $this->container->get('config.factory')->getEditable('migrate_plus.migration.mixed_terms')->set('source', $source)->save();
+    // Flush cache so the recently changed migration can be refreshed.
+    drupal_flush_all_caches();
+    $this->drush('mim', ['mixed_terms'], ['sync' => NULL, 'update' => NULL]);
+    $this->assertStringContainsString('1/5', $this->getErrorOutput());
+    $this->assertMatchesRegularExpression('/5\/5[^\n]+\[notice\][^\n]+Processed 5 items \(1 created, 4 updated, 0 failed, 0 ignored\) - done with \'mixed_terms\'/', $this->getErrorOutput());
+    $terms = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->loadMultiple();
+    $this->assertEquals(5, count($terms));
+
+    // If keys are converted correctly terms should not get deleted and
+    // recreated, so we should still have IDs 1 to 5.
+    $this->assertEquals(range(1, 5), array_keys($terms));
+  }
+
 }