Let us try to understand this in a simplified manner.
Both postTargetProcess and postTargetProcessSetBased are hook methods on a data entity. They run after data has been pushed from the staging table into the actual target table(s) during import. You override them when you want to add extra logic after records land in the real table (like updating a related table, triggering a workflow, logging something, etc.)
The difference is which import mode triggers them.
postTargetProcess
This runs when the import uses record-by-record processing (also called RBO — Record By Object).
In this mode, D365 loops through each staging record one at a time, creates/updates the actual record using normal X++ (insert(), update(), business logic, validations, etc.), and after each record is processed, it calls postTargetProcess for that record.
Since it's per-record, you get the actual buffer/record easily and can act on it individually.
postTargetProcessSetBased
This runs when the import uses set-based processing — meaning D365 skips the one-by-one X++ record loop and instead does a bulk SQL operation (like a single INSERT INTO...SELECT) to move all staging rows into the target table at once.
This is much faster for large volumes, but because it's pure SQL and not going through normal record-by-record business logic, the framework calls postTargetProcessSetBased once, after the whole set-based operation is done — not per record.
Let us try to understand this through a basic example : -
postTargetProcess is manually handing over items to someone one at a time, and every time you hand one over, you also whisper a quick instruction ("also update this").
postTargetProcessSetBased is dumping the whole box of items in one go, and only giving one instruction at the very end for the whole batch.
Now your main question — do both get called, or just one?
Only one gets called — never both — for a given import run of that entity.
It depends on which processing mode the entity/import actually used, which is decided based on:
Whether "Set-based processing" is enabled on the entity/import project execution settings.
Whether the entity supports set-based processing at all — not all entities can use it. If the entity has complex business logic, validations, or per-record events that must fire, D365 won't allow set-based mode, and it'll silently fall back to record-by-record.
So if the run happens in record-by-record mode → only postTargetProcess fires (once per record).
If the run happens in set-based mode → only postTargetProcessSetBased fires (once for the whole batch).
Note
If you're customizing an entity and want your logic to work correctly no matter which mode gets picked at runtime (since the same entity might sometimes run set-based and sometimes fallback to record-based, depending on data volume/settings), it's safest to override both methods with equivalent logic — otherwise your custom logic might silently not run if the import decides to use the other mode.
Let us now try to understand this through a x++ code example : -
Here's a simple example. Let's say you want to update a custom field CustLastImportedDate on the CustTable whenever customer records are imported through a data entity.
// Extension class for the data entity (e.g., CustCustomerEntity)
[ExtensionOf(tableStr(CustCustomerEntity))]
final class CustCustomerEntity_Extension
{
// Called when import runs in RECORD-BY-RECORD mode
public void postTargetProcess(Common _sourceStagingBuffer, Common _targetBuffer)
{
next postTargetProcess(_sourceStagingBuffer, _targetBuffer);
CustTable custTable = _targetBuffer as CustTable;
if (custTable)
{
this.updateLastImportedDate(custTable.RecId);
}
}
// Called when import runs in SET-BASED mode
public void postTargetProcessSetBased(DataEntityRuntimeContext _dataEntityRuntimeContext,
RefTableId _targetTableId,
RefRecId _fromRecId,
RefRecId _toRecId)
{
next postTargetProcessSetBased(_dataEntityRuntimeContext, _targetTableId, _fromRecId, _toRecId);
// Loop through the range of RecIds that got inserted/updated in this batch
CustTable custTable;
while select custTable
where custTable.TableId == _targetTableId
&& custTable.RecId >= _fromRecId
&& custTable.RecId <= _toRecId
{
this.updateLastImportedDate(custTable.RecId);
}
}
// Shared logic used by both methods, so you write it only once
private void updateLastImportedDate(RecId _custTableRecId)
{
CustTable custTable = CustTable::findRecId(_custTableRecId, true); // forUpdate = true
ttsbegin;
custTable.CustLastImportedDate = DateTimeUtil::date(DateTimeUtil::getSystemDateTime());
custTable.update();
ttscommit;
}
}
Important points to follow in the above code example
- Both methods call the same shared private method (
updateLastImportedDate) — this way you write your actual business logic only once, and both hooks just feed it the right record(s).
- postTargetProcess gives you a direct buffer (
_targetBuffer), since it's per-record.
- postTargetProcessSetBased does not give you a buffer — it only gives you a range of RecIds (
_fromRecId to _toRecId) that were affected in that bulk operation. So you have to query the table yourself for records in that range.
- Always wrap updates in
ttsbegin/ttscommit since you're doing DB writes outside the normal insert/update flow.
In set-based mode, you're working with a range of RecIds, not individual field-level info from staging — so if your logic needs specific staging data (like a source system value not present in the target table), set-based mode makes that harder to access. In such cases, you may need to either:
- Force the entity to always use record-by-record mode for that scenario, or
- Query the staging table directly using the RecId range as a filter.
Please let us know if this makes sense to you also please post if you have any questions or doubts we will be happy to discuss.
All the best !!!