Skip to content

Implement Data Import — CSV & Excel Parsing

Granit.DataExchange provides a full import/export pipeline for tabular data. This guide builds an import (and a companion export) for a Patient entity end to end: upload a file, preview headers, confirm column mappings, then execute a typed pipeline with batched persistence and errors-as-data reporting — a bad row never throws, it surfaces as an ImportRowError in the final report.

  • A working Granit application with EF Core configured
  • A domain entity to import into (e.g., Patient)
  • Granit.Validation for row-level validation
  • A registered ICommandSenderGranit.Wolverine is the reference provider. Both the import and export orchestrators take a hard constructor dependency on it (there is no in-process fallback); DI resolution fails at startup without one.
Terminal window
dotnet add package Granit.DataExchange
dotnet add package Granit.DataExchange.EntityFrameworkCore
dotnet add package Granit.DataExchange.Endpoints
# ICommandSender is required for asynchronous execution — Granit.Wolverine
# is the reference provider (durable outbox via PostgreSQL)
dotnet add package Granit.Wolverine
dotnet add package Granit.Wolverine.Postgresql

Then add one or both file parsers:

Terminal window
dotnet add package Granit.DataExchange.Csv
// Import pipeline (mapping suggestions, orchestrator, job store defaults)
services.AddGranitDataImport();
// Export pipeline (orchestrator, preset store defaults)
services.AddGranitDataExport();
// File parsers (at least one required) — also register the matching
// IExportWriter (CsvExportWriter / ClosedXmlExportWriter), so no separate
// export-writer registration is needed
services.AddGranitDataExchangeCsv();
services.AddGranitDataExchangeExcel();
// EF Core persistence (import/export job store, saved mappings, presets)
builder.AddGranitDataExchangeEntityFrameworkCore(opts =>
opts.UseNpgsql(connectionString));
// Opts AppDbContext into export discovery: gives the automatic export
// fallback AND the generic IExportDataSource<T> that an explicit
// ExportDefinition<T> can reuse (unless it needs navigation fields)
services.AddDataExchangeDbContext<AppDbContext>();
// Import definition, executor, and identity resolver for your entity
services.AddImportDefinition<Patient, PatientImportDefinition>();
services.AddImportExecutor<Patient, AppDbContext>();
services.AddBusinessKeyResolver<Patient, AppDbContext>();
// Export definition for your entity
services.AddExportDefinition<Patient, PatientExportDefinition>();

Each importable entity needs an ImportDefinition<TEntity> that declares which properties are importable (whitelist), their display names, aliases, and the business key used for INSERT vs UPDATE resolution:

public sealed class PatientImportDefinition : ImportDefinition<Patient>
{
public override string Name => "Acme.PatientImport";
protected override void Configure(ImportDefinitionBuilder<Patient> builder)
{
builder
.HasBusinessKey(p => p.Niss)
.Property(p => p.Niss, p => p
.DisplayName("NISS")
.Aliases("National ID", "Numéro national")
.Required())
.Property(p => p.FirstName, p => p
.DisplayName("First name")
.Aliases("Prénom", "Voornaam"))
.Property(p => p.LastName, p => p
.DisplayName("Last name")
.Aliases("Nom", "Achternaam"))
.Property(p => p.Email, p => p
.DisplayName("Email")
.Aliases("Courriel", "Mail", "E-mail"))
.Property(p => p.BirthDate, p => p
.DisplayName("Date of birth")
.Format("dd/MM/yyyy"))
.ExcludeOnUpdate(p => p.CreatedAt);
}
}
MethodDescription
Property(expr, config?)Declare an importable property (whitelist)
HasBusinessKey(expr)Single natural key for INSERT vs UPDATE resolution
HasCompositeKey(exprs)Multi-column business key
HasExternalId()External ID column for cross-system identity mapping (via a dedicated mapping table)
ExcludeOnUpdate(expr)Never overwrite this property on UPDATE

The pipeline processes files through several stages, all streaming via IAsyncEnumerable. Only the current batch (default 500 entities) and accumulated errors are held in memory. Mapping, validation, and identity resolution never throw for a bad row — each source row yields a RowOutcome<TEntity> (Ok, Failed, or Skipped), and every failure surfaces as an ImportRowError in the final ImportReport.

Upload --> Preview --> Confirm mappings --> Execute (async)

IFileParser reads the uploaded file and produces a stream of RawImportRow values. Each row contains a dictionary of column names to string values.

services.AddGranitDataExchangeCsv();
// Sep (MIT, SIMD AVX-512/NEON) handles RFC 4180 quoting,
// configurable separators, and UTF-8 with BOM detection.

FileParsingOptions properties relevant to CSV:

OptionDefaultDescription
Separator","Column separator
EncodingnullFile encoding (null = UTF-8 auto-detect)
SkipRows0Rows to skip before the header

IMappingSuggestionService suggests column-to-property mappings using four strategies in decreasing confidence order. Each tier only processes columns the previous tiers couldn’t match:

LevelSourceConfidence
1Saved mappings from previous importsSaved
2Exact match on property name, DisplayName, or aliasExact
3Fuzzy match via normalized Levenshtein distanceFuzzy
4Semantic match via AI (optional ISemanticMappingService)Semantic

IRowValidator<T> validates each mapped entity using FluentValidation. Rows that fail validation are folded into the RowOutcome<T> stream as Failed, with their error codes and messages collected in the final report — they never abort the batch.

IImportExecutor<T> persists entities in batches (default 500 rows per SaveChanges), consuming the IAsyncEnumerable<RowOutcome<T>> stream produced by mapping and validation. Before each batch is written, IRecordIdentityResolver<T>.ResolveBatchAsync resolves the whole batch at once (not row by row) into Insert, Update, Upsert, Skip, or Ambiguous operations — one WHERE key IN (...) query per batch instead of one round-trip per row.

app.MapGranitDataExchange();

All endpoints require a flat permission modelExecute does not imply Read. Grant both to a role that needs full access.

MethodRoutePermissionDescription
GET/jobsImports.ReadList import jobs
POST/jobsImports.ExecuteUpload file (creates import job)
POST/{jobId}/previewImports.ExecuteExtract headers and generate mapping suggestions
PUT/{jobId}/mappingsImports.ExecuteConfirm column mappings
POST/{jobId}/executeImports.ExecuteExecute import asynchronously
POST/{jobId}/dry-runImports.ExecuteFull pipeline with transaction rollback
GET/{jobId}Imports.ReadJob status
DELETE/{jobId}Imports.ExecuteCancel import job
GET/{jobId}/reportImports.ReadImport report (statistics + errors)
GET/{jobId}/correction-fileImports.ReadDownload CSV with failed rows only

Retrieve the aggregate statistics and only the rows that failed — not the successful rows — via GET /{jobId}/report, or programmatically from the ImportReport returned by IImportExecutor<T>.ExecuteAsync:

// rowOutcomes: IAsyncEnumerable<RowOutcome<Patient>> from the mapping +
// validation stages
ImportReport report = await executor.ExecuteAsync(rowOutcomes, options);
report.TotalRows; // 100000
report.SucceededRows; // 99950
report.FailedRows; // 50
report.InsertedRows; // 80000
report.UpdatedRows; // 19950
report.Duration; // TimeSpan

For 100,000 rows with 50 errors, only ~50 ImportRowError objects are held in memory.

Granit.DataExchange also provides a full export pipeline with fluent field definitions, saveable presets, and asynchronous dispatch (every export request is dispatched via ICommandSender — there is no synchronous path).

public sealed class PatientExportDefinition : ExportDefinition<Patient>
{
public override string Name => "Acme.PatientExport";
public override string? QueryDefinitionName => "Acme.Patients";
protected override void Configure(ExportDefinitionBuilder<Patient> builder)
{
builder
.IncludeBusinessKey()
.Field(p => p.LastName, f => f.Header("Last name"))
.Field(p => p.FirstName, f => f.Header("First name"))
.Field(p => p.Email)
.Field(p => p.BirthDate, f => f.Header("Date of birth").Format("dd/MM/yyyy"));
}
}

Already covered in step 2AddGranitDataExport(), AddExportDefinition<Patient, PatientExportDefinition>(), and AddDataExchangeDbContext<AppDbContext>() are all that’s needed here since this definition has no navigation fields. The IExportWriter implementations (CsvExportWriter, ClosedXmlExportWriter) came from AddGranitDataExchangeCsv() / AddGranitDataExchangeExcel() in step 2 — no separate registration required. A navigation field (dot-notation traversal) would instead need a custom IExportDataSource<T> registered explicitly.

MethodRoutePermissionDescription
GET/metadata/definitionsExports.ReadList registered export definitions
GET/metadata/definitions/{name}/fieldsExports.ReadAvailable fields for a definition
POST/export/jobsExports.ExecuteCreate and dispatch an export job
GET/export/jobs/{id}Exports.ReadJob status
GET/export/jobs/{id}/downloadExports.ReadDownload exported file
{
"DataExchange:Import": {
"DefaultMaxFileSizeMb": 50,
"DefaultBatchSize": 500,
"FuzzyMatchThreshold": 0.8,
"DeleteUploadedFileOnSuccess": false
}
}

DataExchange:Export currently has no settings of its own — every export is dispatched asynchronously, so there is no more background-vs-synchronous threshold to configure. Export sizing/dispatch is controlled entirely by the caller’s request and each writer’s own limits (e.g. the Excel 1,048,576-row worksheet cap).