diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 13644328164..1fc68b0ecef 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -876,7 +876,7 @@ CheckAndReportConflict(ResultRelInfo *resultRelInfo, EState *estate,
 	if (conflicttuples)
 		ReportApplyConflict(estate, resultRelInfo, ERROR,
 							list_length(conflicttuples) > 1 ? CT_MULTIPLE_UNIQUE_CONFLICTS : type,
-							searchslot, remoteslot, conflicttuples);
+							NULL, searchslot, remoteslot, conflicttuples);
 }
 
 /*
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index 9791a023201..ef19093001c 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -165,6 +165,7 @@ static Datum build_local_conflicts_json_array(List *conflicttuples);
 static void insert_conflict_log_tuple(EState *estate, Relation rel,
 									  Relation conflictlogrel,
 									  ConflictType conflict_type,
+									  Oid searchindexoid,
 									  TupleTableSlot *searchslot,
 									  List *conflicttuples);
 static void lock_conflictlog_relation(Oid relid);
@@ -320,6 +321,11 @@ GetTupleTransactionInfo(TupleTableSlot *localslot, TransactionId *xmin,
  * This function is used to report a conflict while applying replication
  * changes.
  *
+ * 'relmapentry' is the relation map entry that was used to search the local
+ * row, or NULL if no search was done (ERROR-level insert conflicts). If the
+ * search used the replica identity or primary key index, the caller must have
+ * locked it, which the search itself does, see FindReplTupleInLocalRel().
+ *
  * 'searchslot' should contain the tuple used to search the local row to be
  * updated or deleted.
  *
@@ -333,16 +339,27 @@ GetTupleTransactionInfo(TupleTableSlot *localslot, TransactionId *xmin,
  */
 void
 ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
-					ConflictType type, TupleTableSlot *searchslot,
+					ConflictType type, LogicalRepRelMapEntry *relmapentry,
+					TupleTableSlot *searchslot,
 					TupleTableSlot *remoteslot, List *conflicttuples)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
 	ConflictLogDest dest;
+	Oid			searchindexoid = InvalidOid;
 	Relation	conflictlogrel;
 	StringInfoData err_detail;
 
 	pgstat_report_subscription_conflict(MySubscription->oid, type);
 
+	/*
+	 * Use the replica identity or primary key index that the search actually
+	 * used, which the caller has locked, rather than looking it up again.
+	 * Concurrent DDL could have changed which index that is since the search,
+	 * see comments in FindReplTupleInLocalRel().
+	 */
+	if (relmapentry && relmapentry->idxisreplident)
+		searchindexoid = relmapentry->localindexoid;
+
 	conflictlogrel = get_conflictlog_dest_and_table(elevel, &dest);
 
 	initStringInfo(&err_detail);
@@ -406,6 +423,7 @@ ReportApplyConflict(EState *estate, ResultRelInfo *relinfo, int elevel,
 								  relinfo->ri_RelationDesc,
 								  conflictlogrel,
 								  type,
+								  searchindexoid,
 								  searchslot,
 								  conflicttuples);
 		table_close(conflictlogrel, NoLock);
@@ -472,10 +490,7 @@ get_conflictlog_dest_and_table(int elevel, ConflictLogDest *log_dest)
 		return NULL;
 	}
 
-	/*
-	 * Convert the text log destination to the internal enum.  MySubscription
-	 * already contains the data from pg_subscription.
-	 */
+	/* Convert the text log destination to the internal enum. */
 	*log_dest = GetConflictLogDest(MySubscription->conflictlogdest);
 
 	/* Quick exit if a conflict log table was not requested. */
@@ -1012,7 +1027,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 /*
  * Helper function to extract the "raw" index key Datums and their null flags
  * from a TupleTableSlot, given an already open index descriptor.
- * This is the reusable core logic.
  */
 static void
 build_index_datums_from_slot(EState *estate, Relation localrel,
@@ -1023,19 +1037,17 @@ build_index_datums_from_slot(EState *estate, Relation localrel,
 	TupleTableSlot *tableslot = slot;
 
 	/*
-	 * If the slot is a virtual slot, copy it into a heap tuple slot as
-	 * FormIndexDatum only works with heap tuple slots.
+	 * FormIndexDatum needs an EState whose per-tuple econtext's scantuple
+	 * points to the slot holding the tuple, to evaluate any index expressions.
+	 * It also requires a heap tuple slot, so copy a virtual slot into one,
+	 * created in the EState's tuple table so that it is freed along with it.
 	 */
 	if (TTS_IS_VIRTUAL(slot))
 	{
-		/* Slot is created within the EState's tuple table */
 		tableslot = table_slot_create(localrel, &estate->es_tupleTable);
 		tableslot = ExecCopySlot(tableslot, slot);
 	}
 
-	/*
-	 * Initialize ecxt_scantuple for potential use in FormIndexDatum
-	 */
 	GetPerTupleExprContext(estate)->ecxt_scantuple = tableslot;
 
 	/* Form the index datums */
@@ -1084,6 +1096,7 @@ build_index_value_desc(EState *estate, Relation localrel, TupleTableSlot *slot,
  *
  * The caller must ensure that the index with the OID 'indexid' is locked so
  * that we can safely fetch and construct the replica identity key values.
+ * See ReportApplyConflict() for how this holds.
  */
 static Datum
 build_index_key_json(EState *estate, Relation localrel,
@@ -1108,19 +1121,15 @@ build_index_key_json(EState *estate, Relation localrel,
 								 isnull);
 
 	/*
-	 * Build the JSON object here rather than handing the values to
-	 * row_to_json(), and render each of them with its type's output function.
-	 *
-	 * row_to_json() would go through json_categorize_type(), which honours a
-	 * user CREATE CAST (t AS json) for any type at or above
-	 * FirstNormalObjectId.  That cast is a function no other part of apply
-	 * ever calls -- the publisher sends the value using the type's output
-	 * function, the index compares it with the opclass, and neither asks for
-	 * a json representation -- so its result is unrelated to the size of the
-	 * value we received, and a tiny key could render a json value above the
-	 * 1GB limit and error out the apply worker.  Using the output function
-	 * keeps us to the same representation the publisher already produced, as
-	 * the server log does in BuildIndexValueDescription().
+	 * Build the JSON object here rather than passing the values to
+	 * row_to_json().  For a type at or above FirstNormalObjectId,
+	 * json_categorize_type() would use a user-defined cast to json if one
+	 * exists.  Nothing else in apply calls that cast, so its output is
+	 * unrelated to the size of the value we received, and a small key could
+	 * produce a json value over the 1GB limit and fail the apply worker.
+	 * Rendering each value with its type's output function keeps us to the
+	 * representation the publisher sent, and matches what the server log
+	 * does in BuildIndexValueDescription().
 	 *
 	 * Only the key attributes are recorded.  FormIndexDatum() above also
 	 * fills in any non-key (INCLUDE) columns, but those are not part of the
@@ -1359,6 +1368,7 @@ static void
 insert_conflict_log_tuple(EState *estate, Relation rel,
 						  Relation conflictlogrel,
 						  ConflictType conflict_type,
+						  Oid searchindexoid,
 						  TupleTableSlot *searchslot,
 						  List *conflicttuples)
 {
@@ -1423,10 +1433,10 @@ insert_conflict_log_tuple(EState *estate, Relation rel,
 	 */
 	Assert(!TupIsNull(searchslot));
 
-	replica_index = GetRelationIdentityOrPK(rel);
+	replica_index = searchindexoid;
 
 	/*
-	 * If the table has a valid replica identity index, build the index JSON
+	 * If the search used a replica identity index, build the index JSON
 	 * datum from key value. Otherwise, in REPLICA IDENTITY FULL cases, set
 	 * replica_identity_full to true and leave replica_identity NULL to avoid
 	 * serializing full tuples that could exceed memory allocation limits.
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index c46afc2f55b..56abc2cdc0b 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -3011,7 +3011,7 @@ apply_handle_update_internal(ApplyExecutionData *edata,
 			conflicttuple.slot = localslot;
 
 			ReportApplyConflict(estate, relinfo, LOG, CT_UPDATE_ORIGIN_DIFFERS,
-								remoteslot, newslot,
+								relmapentry, remoteslot, newslot,
 								list_make1(&conflicttuple));
 		}
 
@@ -3054,7 +3054,8 @@ apply_handle_update_internal(ApplyExecutionData *edata,
 		 * The tuple to be updated could not be found or was deleted.  Do
 		 * nothing except for emitting a log message.
 		 */
-		ReportApplyConflict(estate, relinfo, LOG, type, remoteslot, newslot,
+		ReportApplyConflict(estate, relinfo, LOG, type, relmapentry,
+							remoteslot, newslot,
 							type == CT_UPDATE_DELETED ? list_make1(&conflicttuple) : NIL);
 	}
 
@@ -3198,7 +3199,7 @@ apply_handle_delete_internal(ApplyExecutionData *edata,
 		{
 			conflicttuple.slot = localslot;
 			ReportApplyConflict(estate, relinfo, LOG, CT_DELETE_ORIGIN_DIFFERS,
-								remoteslot, NULL,
+								relmapentry, remoteslot, NULL,
 								list_make1(&conflicttuple));
 		}
 
@@ -3215,7 +3216,7 @@ apply_handle_delete_internal(ApplyExecutionData *edata,
 		 * emitting a log message.
 		 */
 		ReportApplyConflict(estate, relinfo, LOG, CT_DELETE_MISSING,
-							remoteslot, NULL, NIL);
+							relmapentry, remoteslot, NULL, NIL);
 	}
 
 	/* Cleanup. */
@@ -3575,7 +3576,8 @@ apply_handle_tuple_routing(ApplyExecutionData *edata,
 					 * deleted.  Do nothing except for emitting a log message.
 					 */
 					ReportApplyConflict(estate, partrelinfo, LOG,
-										type, remoteslot_part, newslot,
+										type, part_entry,
+										remoteslot_part, newslot,
 										type == CT_UPDATE_DELETED ? list_make1(&conflicttuple) : NIL);
 
 					return;
@@ -3599,7 +3601,7 @@ apply_handle_tuple_routing(ApplyExecutionData *edata,
 					conflicttuple.slot = localslot;
 
 					ReportApplyConflict(estate, partrelinfo, LOG, CT_UPDATE_ORIGIN_DIFFERS,
-										remoteslot_part, newslot,
+										part_entry, remoteslot_part, newslot,
 										list_make1(&conflicttuple));
 				}
 
diff --git a/src/include/replication/conflict.h b/src/include/replication/conflict.h
index 326570ed639..5643052a133 100644
--- a/src/include/replication/conflict.h
+++ b/src/include/replication/conflict.h
@@ -15,6 +15,7 @@
 
 /* Avoid including execnodes.h here */
 typedef struct EState EState;
+typedef struct LogicalRepRelMapEntry LogicalRepRelMapEntry;
 typedef struct ResultRelInfo ResultRelInfo;
 typedef struct TupleTableSlot TupleTableSlot;
 
@@ -111,6 +112,7 @@ extern bool GetTupleTransactionInfo(TupleTableSlot *localslot,
 									TimestampTz *localts);
 extern void ReportApplyConflict(EState *estate, ResultRelInfo *relinfo,
 								int elevel, ConflictType type,
+								LogicalRepRelMapEntry *relmapentry,
 								TupleTableSlot *searchslot,
 								TupleTableSlot *remoteslot,
 								List *conflicttuples);
