A CSV upload shouldn't write anything to your database until the user has seen what's in the file. This tutorial shows how to build a CSV import preview in ASP.NET Core that parses the file first, flags errors row by row, checks for duplicate order IDs, and saves only after the user approves.
You'll build it with Razor Pages, CsvHelper, Entity Framework Core (EF Core), and SQL Server. The workflow has two stages: preview first, save after approval. A unique database index and a single SaveChangesAsync call protect against duplicate records and half-saved batches.
Short answer: Parse the uploaded CSV into preview rows without inserting anything. Collect validation errors per row, detect duplicate IDs inside the file and in the database, and let the user approve the rows that are ready. Save those rows with one EF Core SaveChangesAsync call, and enforce uniqueness with a unique index in SQL Server.
This is the next step after the tutorial on reading a CSV file in ASP.NET with C#, which covers basic parsing. Here the focus is what happens after parsing: previewing results, reviewing errors, checking duplicates, and approving the import.
Prerequisites
- The .NET 10 SDK and basic C# and Razor Pages knowledge.
- SQL Server. On Windows, LocalDB (installed with Visual Studio) works for development. On macOS or Linux, use SQL Server in a container.
- A Razor Pages project with user sign-in. The import page requires an authenticated user, so the next section creates one with ASP.NET Core Identity.
1. How the CSV import preview workflow works in ASP.NET Core
The import has two stages. The preview stage reads and checks the file but creates no order records. The approval stage takes the rows that passed and saves them as one database batch. The diagram below shows the full path, including the point where the user can walk away with nothing saved.
The boundary that matters is the approval step. Parsing, validation, and duplicate checks can all run before it, but none of them may create order records. That way, simply choosing a file never changes the database.
2. Example CSV file and import rules
The example uses an order file with six columns. Save it as orders.csv for testing later:
Id,CustomerName,Item,Qty,Price,Amount
1001,John Smith,Laptop,1,1200.00,1200.00
1002,Emma Wilson,Keyboard,2,45.00,90.00
1003,Michael Brown,Monitor,1,250.00,250.00
1002,Olivia Davis,Mouse,1,25.00,25.00
1004,,Headset,1,80.00,80.00
1005,Daniel Lee,Webcam,0,55.00,0.00
This file contains three problems the preview should catch:
- Order ID
1002appears twice. - Order
1004has no customer name. - Order
1005has a quantity of zero.
A row is ready only when every field is valid and its order ID isn't duplicated in the file or the database. This tutorial uses a partial-valid-row policy: the user approves the ready rows, and rows with errors are left out. If your business needs the whole file to be valid first, see the variation in section 11.
| Column | Validation rule |
|---|---|
Id |
Required positive integer. Must be unique in the file and the database. |
CustomerName |
Required text, up to 150 characters. |
Item |
Required text, up to 150 characters. |
Qty |
Integer greater than zero. |
Price |
Non-negative decimal with up to two decimal places. |
Amount |
Non-negative decimal with up to two decimal places. |
These are example business rules, not CSV requirements. Adjust them to match your own database constraints. The sample also treats Amount as a value supplied by the file rather than calculating it from quantity times price.
3. Create the ASP.NET Core project and install packages
The example targets .NET 10, Razor Pages, EF Core 10, and SQL Server. The commands below don't pin package versions, so NuGet installs the latest stable release. The code uses CsvHelper's CsvReader with CsvConfiguration, which is the API of the 30.x and 33.x releases. If you pin an older or newer major version, check its release notes for API changes.
If you're starting from scratch, create a Razor Pages app with sign-in. On Windows, --use-local-db makes the template use SQL Server LocalDB instead of SQLite:
dotnet new webapp -au Individual --use-local-db -o CodingvilaCsvImport
cd CodingvilaCsvImport
dotnet add package CsvHelper
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool update --global dotnet-ef
The Identity template has its own ApplicationDbContext and an initial migration. Apply it, then run the app, register a user, and sign in (the template's development setup shows a confirmation link instead of sending email):
dotnet ef database update --context ApplicationDbContext
If you already have an app with authentication, keep your existing setup. Just make sure users are authenticated before they reach the import page.
Suggested folder structure
CodingvilaCsvImport/
Data/
AppDbContext.cs
Models/
Order.cs
OrderRow.cs
CsvImportPreviewRow.cs
CsvImportPreviewResult.cs
Services/
OrderCsvImporter.cs
OrderDuplicateChecker.cs
Pages/
Import.cshtml
Import.cshtml.cs
Program.cs
Any structure works. Just keep parsing, duplicate checking, database access, and the page separate so each piece can be reused and tested on its own.
4. Create the import and preview models
The database entity and the parsed CSV record have different jobs. The CSV's Id is the business order number, so the entity stores it as ExternalOrderId and keeps its own generated primary key. That way a value from an uploaded file never becomes your identity column.
Order.cs
namespace CodingvilaCsvImport.Models;
public sealed class Order
{
public int Id { get; set; }
public int ExternalOrderId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string Item { get; set; } = string.Empty;
public int Qty { get; set; }
public decimal Price { get; set; }
public decimal Amount { get; set; }
}
OrderRow.cs
This record holds a successfully parsed row. It isn't an EF Core entity and is never saved directly.
namespace CodingvilaCsvImport.Models;
public sealed record OrderRow(
int Id,
string CustomerName,
string Item,
int Qty,
decimal Price,
decimal Amount
);
CsvImportPreviewRow.cs
Every CSV record gets a preview object. It keeps the raw field values for display, the record number, the order ID when one could be read, the parsed data when the row is valid, and a list of error messages.
namespace CodingvilaCsvImport.Models;
public sealed class CsvImportPreviewRow
{
public long RecordNumber { get; init; }
public string[] Fields { get; init; } = Array.Empty<string>();
public int? ParsedOrderId { get; set; }
public OrderRow? Data { get; set; }
public List<string> Errors { get; } = new();
public bool IsReady => Data is not null && Errors.Count == 0;
}
CsvImportPreviewResult.cs
The result holds all preview rows plus an optional file-level error. A file-level error is different from a row error. If the CSV is malformed badly enough that the parser can't continue reliably, reject the whole preview instead of showing a partial parse as if it were complete.
namespace CodingvilaCsvImport.Models;
public sealed class CsvImportPreviewResult
{
public List<CsvImportPreviewRow> Rows { get; } = new();
public string? FileError { get; set; }
}
5. Parse the CSV and collect errors for every row
A fail-fast reader stops at the first bad row. A preview reader does the opposite: it builds a result for every readable record, attaches that record's errors, and keeps going. That lets the user fix several problems in one pass.
The importer below reads the first record as the header when hasHeader is true and checks the column names. It also sets two defensive limits: 10,000 data rows and 4,096 characters per field. Both are example values, so choose limits that fit your files and hosting.
OrderCsvImporter.cs
using System.Globalization;
using System.Text;
using CodingvilaCsvImport.Models;
using CsvHelper;
using CsvHelper.Configuration;
namespace CodingvilaCsvImport.Services;
public sealed class OrderCsvImporter
{
private const int MaxRows = 10_000;
private const int MaxFieldSize = 4096;
private static readonly string[] ExpectedHeaders =
{ "Id", "CustomerName", "Item", "Qty", "Price", "Amount" };
public async Task<CsvImportPreviewResult> ReadForPreviewAsync(
Stream stream,
bool hasHeader,
CancellationToken cancellationToken = default)
{
var result = new CsvImportPreviewResult();
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
// The first record is handled manually below so record
// numbers match the physical order of records in the file.
HasHeaderRecord = false,
TrimOptions = TrimOptions.Trim,
MaxFieldSize = MaxFieldSize
};
using var reader = new StreamReader(
stream,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 1024,
leaveOpen: true);
using var csv = new CsvReader(reader, config);
try
{
var isFirstRecord = true;
while (await csv.ReadAsync())
{
cancellationToken.ThrowIfCancellationRequested();
var fields = new string[csv.Parser.Count];
for (var index = 0; index < fields.Length; index++)
{
fields[index] = (csv.GetField(index) ?? string.Empty).Trim();
}
if (isFirstRecord && hasHeader)
{
isFirstRecord = false;
if (!HeaderMatches(fields))
{
result.FileError =
"The header must be: " +
string.Join(",", ExpectedHeaders) + ".";
return result;
}
continue;
}
isFirstRecord = false;
if (result.Rows.Count >= MaxRows)
{
result.Rows.Clear();
result.FileError =
$"The file exceeds the limit of {MaxRows:N0} data rows.";
return result;
}
var previewRow = new CsvImportPreviewRow
{
RecordNumber = csv.Parser.Row,
Fields = fields
};
ValidateRow(fields, previewRow);
result.Rows.Add(previewRow);
}
}
catch (CsvHelperException)
{
result.Rows.Clear();
result.FileError =
"The CSV is malformed or a field is too large. " +
"Check quotes and delimiters. Nothing was saved.";
return result;
}
if (result.Rows.Count == 0)
{
result.FileError = "The uploaded CSV does not contain any data rows.";
}
return result;
}
private static bool HeaderMatches(string[] fields)
{
if (fields.Length != ExpectedHeaders.Length)
{
return false;
}
for (var index = 0; index < fields.Length; index++)
{
if (!string.Equals(
fields[index],
ExpectedHeaders[index],
StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
return true;
}
private static void ValidateRow(string[] fields, CsvImportPreviewRow row)
{
var errors = row.Errors;
// Keep a valid ID even if another column is bad, so duplicate
// detection still covers partially invalid rows.
if (fields.Length > 0 &&
int.TryParse(
fields[0],
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var possibleId) &&
possibleId > 0)
{
row.ParsedOrderId = possibleId;
}
if (fields.Length != ExpectedHeaders.Length)
{
errors.Add($"Expected 6 columns, but found {fields.Length}.");
return;
}
if (row.ParsedOrderId is null)
{
errors.Add("Order ID must be a positive integer.");
}
var customerName = fields[1];
if (string.IsNullOrWhiteSpace(customerName))
{
errors.Add("Customer name is required.");
}
else if (customerName.Length > 150)
{
errors.Add("Customer name cannot exceed 150 characters.");
}
var item = fields[2];
if (string.IsNullOrWhiteSpace(item))
{
errors.Add("Item is required.");
}
else if (item.Length > 150)
{
errors.Add("Item cannot exceed 150 characters.");
}
if (!int.TryParse(
fields[3],
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var qty) ||
qty <= 0)
{
errors.Add("Quantity must be a positive integer.");
}
if (!TryMoney(fields[4], out var price))
{
errors.Add("Price must be a non-negative amount with up to two decimal places.");
}
if (!TryMoney(fields[5], out var amount))
{
errors.Add("Amount must be a non-negative amount with up to two decimal places.");
}
if (errors.Count == 0 && row.ParsedOrderId is int id)
{
row.Data = new OrderRow(id, customerName, item, qty, price, amount);
}
}
private static bool TryMoney(string value, out decimal amount)
{
var parsed = decimal.TryParse(
value,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out amount);
return parsed
&& amount >= 0
&& decimal.Round(amount, 2) == amount;
}
}
How this importer behaves
The Fields array keeps the raw values for the preview table. The Data property is filled only when the row passes every field check, so an invalid row can be displayed without pretending it can be saved.
The importer also stores a valid ParsedOrderId before checking the other columns. If two records share an ID but one has a missing customer name, both should still be reported as duplicates.
Numbers are parsed with the invariant culture, so 1200.00 works no matter what regional settings the server uses. If your files use a different format, such as 1.200,00, define and document the accepted format instead of depending on the server's culture.
Two more details are worth knowing. CsvHelper skips blank lines by default. And the record number counts logical records, not physical lines, because a quoted field can contain a newline. The header counts as record 1, so the first data row shows as record 2.
6. Configure EF Core and enforce unique order IDs
Checking for duplicates in application code gives the user useful feedback, but it can't guarantee uniqueness. Two users could upload the same order ID at nearly the same moment, and both previews would say the ID is free.
The database has to enforce the rule too. Here, ExternalOrderId gets a unique index, so SQL Server rejects a second insert of the same business ID even if both requests passed their earlier checks.
AppDbContext.cs
using CodingvilaCsvImport.Models;
using Microsoft.EntityFrameworkCore;
namespace CodingvilaCsvImport.Data;
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(order => order.Id);
entity.HasIndex(order => order.ExternalOrderId)
.IsUnique()
.HasDatabaseName("UX_Orders_ExternalOrderId");
entity.Property(order => order.CustomerName)
.HasMaxLength(150)
.IsRequired();
entity.Property(order => order.Item)
.HasMaxLength(150)
.IsRequired();
entity.Property(order => order.Price).HasPrecision(18, 2);
entity.Property(order => order.Amount).HasPrecision(18, 2);
});
}
}
If you're adding this to a table that already holds duplicate values, clean those up first. SQL Server can't create a unique index while duplicates exist.
Register the services in Program.cs
Add these registrations to your existing Program.cs. Keep the Identity, authentication, and authorization setup your project already has.
using CodingvilaCsvImport.Data;
using CodingvilaCsvImport.Services;
using Microsoft.EntityFrameworkCore;
// ...inside your existing Program.cs, before builder.Build():
builder.Services.AddMemoryCache();
builder.Services.AddScoped<OrderCsvImporter>();
builder.Services.AddScoped<OrderDuplicateChecker>();
builder.Services.AddDbContext<AppDbContext>(options =>
{
var connectionString =
builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException(
"DefaultConnection connection string is missing.");
options.UseSqlServer(connectionString);
});
The Identity template already defines DefaultConnection in appsettings.json, and this example reuses it, so both contexts share one database. The template's LocalDB connection string is for development only. In production, use your hosted SQL Server, keep credentials in a secret store or environment configuration rather than source control, and don't disable TLS certificate validation.
Create and apply the migration
Because the project now has two contexts, name the one you want:
dotnet ef migrations add CreateOrders --context AppDbContext
dotnet ef database update --context AppDbContext
Expected result: an Orders table in the database with a unique index named UX_Orders_ExternalOrderId.
7. Detect duplicates in the uploaded file and the database
Duplicate detection has two parts. The first finds order IDs that appear more than once in the current file. The second finds IDs that already exist in SQL Server.
When an ID repeats inside the file, this example flags every occurrence. It doesn't quietly keep the first one, because picking a row automatically could import the wrong data. The database lookup runs in batches of 500 IDs so no single query gets huge. That batch size is a reasonable example value, not a rule.
OrderDuplicateChecker.cs
using CodingvilaCsvImport.Data;
using CodingvilaCsvImport.Models;
using Microsoft.EntityFrameworkCore;
namespace CodingvilaCsvImport.Services;
public sealed class OrderDuplicateChecker
{
private const int BatchSize = 500;
private readonly AppDbContext _db;
public OrderDuplicateChecker(AppDbContext db)
{
_db = db;
}
public async Task MarkDuplicatesAsync(
IReadOnlyList<CsvImportPreviewRow> rows,
CancellationToken cancellationToken)
{
var duplicateFileIds = rows
.Where(row => row.ParsedOrderId.HasValue)
.GroupBy(row => row.ParsedOrderId!.Value)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToHashSet();
var databaseIds = await FindExistingOrderIdsAsync(
rows
.Where(row => row.ParsedOrderId.HasValue)
.Select(row => row.ParsedOrderId!.Value),
cancellationToken);
foreach (var row in rows)
{
if (row.ParsedOrderId is not int id)
{
continue;
}
if (duplicateFileIds.Contains(id))
{
row.Errors.Add($"Order ID {id} appears more than once in this file.");
}
if (databaseIds.Contains(id))
{
row.Errors.Add($"Order ID {id} already exists in the database.");
}
}
}
public async Task<HashSet<int>> FindExistingOrderIdsAsync(
IEnumerable<int> orderIds,
CancellationToken cancellationToken)
{
var existingIds = new HashSet<int>();
foreach (var batch in orderIds.Distinct().Chunk(BatchSize))
{
var matches = await _db.Orders
.AsNoTracking()
.Where(order => batch.Contains(order.ExternalOrderId))
.Select(order => order.ExternalOrderId)
.ToListAsync(cancellationToken);
existingIds.UnionWith(matches);
}
return existingIds;
}
}
AsNoTracking is the right choice here. These queries only read IDs, so EF Core doesn't need to track the results.
8. Build the Razor Pages preview and approval handlers
The page model ties everything together: upload checks, parsing, duplicate marking, preview storage, and the final save. The preview lives in memory for 15 minutes. The cache key includes the signed-in user's ID plus a random preview ID, so one user can't load another user's preview by guessing an ID.
The page accepts a 2 MB file inside a 3 MB request. ASP.NET Core buffers IFormFile uploads, which is fine for small files. A high-volume import needs streaming or a staging table instead (see section 12).
Import.cshtml.cs
using System.Security.Claims;
using CodingvilaCsvImport.Data;
using CodingvilaCsvImport.Models;
using CodingvilaCsvImport.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace CodingvilaCsvImport.Pages;
[Authorize]
[RequestSizeLimit(3 * 1024 * 1024)]
public sealed class ImportModel : PageModel
{
private const long MaxFileBytes = 2 * 1024 * 1024;
private readonly AppDbContext _db;
private readonly OrderCsvImporter _importer;
private readonly OrderDuplicateChecker _duplicateChecker;
private readonly IMemoryCache _cache;
private readonly ILogger<ImportModel> _logger;
public ImportModel(
AppDbContext db,
OrderCsvImporter importer,
OrderDuplicateChecker duplicateChecker,
IMemoryCache cache,
ILogger<ImportModel> logger)
{
_db = db;
_importer = importer;
_duplicateChecker = duplicateChecker;
_cache = cache;
_logger = logger;
}
[BindProperty]
public IFormFile? Upload { get; set; }
[BindProperty]
public bool HasHeader { get; set; } = true;
[TempData]
public string? StatusMessage { get; set; }
public List<CsvImportPreviewRow> Rows { get; private set; } = new();
public Guid? PreviewId { get; private set; }
public int ReadyCount => Rows.Count(row => row.IsReady);
public int AttentionCount => Rows.Count(row => !row.IsReady);
private string CurrentUserId =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new InvalidOperationException(
"The authenticated user does not have a user identifier claim.");
public async Task<IActionResult> OnPostPreviewAsync(
CancellationToken cancellationToken)
{
if (Upload is null || Upload.Length == 0)
{
ModelState.AddModelError(nameof(Upload), "Choose a non-empty CSV file.");
return Page();
}
if (Upload.Length > MaxFileBytes)
{
ModelState.AddModelError(nameof(Upload), "The CSV file cannot exceed 2 MB.");
return Page();
}
if (!string.Equals(
Path.GetExtension(Upload.FileName),
".csv",
StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError(nameof(Upload), "Upload a file with the .csv extension.");
return Page();
}
await using var stream = Upload.OpenReadStream();
var result = await _importer.ReadForPreviewAsync(
stream,
HasHeader,
cancellationToken);
if (result.FileError is not null)
{
ModelState.AddModelError(nameof(Upload), result.FileError);
return Page();
}
await _duplicateChecker.MarkDuplicatesAsync(result.Rows, cancellationToken);
Rows = result.Rows;
PreviewId = Guid.NewGuid();
_cache.Set(
CacheKey(CurrentUserId, PreviewId.Value),
Rows,
TimeSpan.FromMinutes(15));
return Page();
}
public async Task<IActionResult> OnPostApproveAsync(
Guid previewId,
CancellationToken cancellationToken)
{
var cacheKey = CacheKey(CurrentUserId, previewId);
if (previewId == Guid.Empty ||
!_cache.TryGetValue(cacheKey, out List<CsvImportPreviewRow>? rows) ||
rows is null)
{
ModelState.AddModelError(
string.Empty,
"This preview has expired or is no longer available. Upload the file again.");
return Page();
}
Rows = rows;
PreviewId = previewId;
var readyRows = rows
.Where(row => row.IsReady && row.Data is not null)
.ToList();
if (readyRows.Count == 0)
{
ModelState.AddModelError(string.Empty, "There are no valid rows available to save.");
return Page();
}
// The preview is only a snapshot. Check again in case the
// database changed after the preview was generated.
var idsNowInDatabase = await _duplicateChecker.FindExistingOrderIdsAsync(
readyRows.Select(row => row.Data!.Id),
cancellationToken);
if (idsNowInDatabase.Count > 0)
{
foreach (var row in readyRows)
{
if (idsNowInDatabase.Contains(row.Data!.Id))
{
row.Errors.Add(
$"Order ID {row.Data.Id} was added to the database after this preview was created.");
}
}
_cache.Remove(cacheKey);
PreviewId = null;
ModelState.AddModelError(
string.Empty,
"The database changed after the preview. Review the errors below, then upload the file again.");
return Page();
}
var orders = readyRows.Select(row => new Order
{
ExternalOrderId = row.Data!.Id,
CustomerName = row.Data.CustomerName,
Item = row.Data.Item,
Qty = row.Data.Qty,
Price = row.Data.Price,
Amount = row.Data.Amount
}).ToList();
_db.Orders.AddRange(orders);
try
{
var savedCount = await _db.SaveChangesAsync(cancellationToken);
_cache.Remove(cacheKey);
StatusMessage = $"{savedCount} order records were saved successfully.";
return RedirectToPage();
}
catch (DbUpdateException exception)
{
_db.ChangeTracker.Clear();
_cache.Remove(cacheKey);
PreviewId = null;
_logger.LogWarning(
exception,
"CSV import save failed for preview {PreviewId}.",
previewId);
ModelState.AddModelError(
string.Empty,
"The database rejected this batch, and nothing was saved. Another import may have added the same order ID. Upload the file again to get a fresh preview.");
return Page();
}
}
private static string CacheKey(string userId, Guid previewId) =>
$"csv-import-preview:{userId}:{previewId:N}";
}
The approval handler checks the database a second time on purpose. A small race window remains between that check and the insert, and the unique index covers it. If another request inserts the same ID in that window, SQL Server rejects the batch and EF Core rolls the failed save back.
9. Create the Razor Pages upload and preview interface
The page has a file input, a header checkbox, a preview table with per-row messages, and an approve button. The approve form posts only the preview ID. The server looks up the rows in its own user-scoped cache, so the browser never sends row data back and can't tamper with it.
Import.cshtml
@page
@using CodingvilaCsvImport.Models
@model CodingvilaCsvImport.Pages.ImportModel
@{
ViewData["Title"] = "CSV Import Preview";
}
<h1>CSV Import Preview</h1>
<p>
Upload an order CSV file to review its rows before saving them to the database.
</p>
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
{
<p role="status">@Model.StatusMessage</p>
}
<div asp-validation-summary="All" role="alert"></div>
<form method="post" asp-page-handler="Preview" enctype="multipart/form-data">
<div class="mb-3">
<label asp-for="Upload" class="form-label">Select CSV file</label>
<input asp-for="Upload" type="file" class="form-control" accept=".csv,text/csv" />
<span asp-validation-for="Upload" class="text-danger"></span>
</div>
<div class="form-check mb-3">
<input asp-for="HasHeader" class="form-check-input" />
<label asp-for="HasHeader" class="form-check-label">
The first record contains column names
</label>
</div>
<button type="submit" class="btn btn-primary">Generate preview</button>
</form>
@if (Model.Rows.Count > 0)
{
<hr />
<h2>Review imported rows</h2>
<p>
Ready rows: <strong>@Model.ReadyCount</strong>.
Rows needing attention: <strong>@Model.AttentionCount</strong>.
</p>
<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th scope="col">Record</th>
<th scope="col">Order ID</th>
<th scope="col">Customer</th>
<th scope="col">Item</th>
<th scope="col">Quantity</th>
<th scope="col">Price</th>
<th scope="col">Amount</th>
<th scope="col">Review result</th>
</tr>
</thead>
<tbody>
@foreach (var row in Model.Rows)
{
<tr>
<td>@row.RecordNumber</td>
<td>@Field(row, 0)</td>
<td>@Field(row, 1)</td>
<td>@Field(row, 2)</td>
<td>@Field(row, 3)</td>
<td>@Field(row, 4)</td>
<td>@Field(row, 5)</td>
<td>
@if (row.IsReady)
{
<strong>Ready</strong>
}
else
{
<strong>Needs attention</strong>
<ul>
@foreach (var error in row.Errors)
{
<li>@error</li>
}
</ul>
}
</td>
</tr>
}
</tbody>
</table>
</div>
@if (Model.PreviewId.HasValue && Model.ReadyCount > 0)
{
<p>
Only rows marked Ready are saved. Rows with errors are excluded.
</p>
<form method="post" asp-page-handler="Approve">
<input type="hidden" name="previewId" value="@Model.PreviewId.Value" />
<button type="submit" class="btn btn-success">
Approve and save @Model.ReadyCount rows
</button>
</form>
}
else
{
<p>
Nothing can be saved from this preview. Correct the CSV file
and generate another preview.
</p>
}
}
@functions {
private static string Field(CsvImportPreviewRow row, int index)
{
return row.Fields.Length > index ? row.Fields[index] : string.Empty;
}
}
Razor Pages validates anti-forgery tokens on POST requests, and the form tag helper adds the token to each form, so leave that protection on. The accept attribute only tunes the browser's file picker. It isn't a security check, which is why the server validates the file itself.
10. Run it and check the output
Start the app with dotnet run, sign in, open /Import, upload the orders.csv file from section 2 with the header box checked, and select Generate preview. On a fresh Orders table, you should see:
| Record | Order ID | Result |
|---|---|---|
| 2 | 1001 | Ready |
| 3 | 1002 | Needs attention: appears more than once in this file |
| 4 | 1003 | Ready |
| 5 | 1002 | Needs attention: appears more than once in this file |
| 6 | 1004 | Needs attention: customer name is required |
| 7 | 1005 | Needs attention: quantity must be a positive integer |
The summary line reads "Ready rows: 2. Rows needing attention: 4." Select Approve and save 2 rows. The page redirects with "2 order records were saved successfully." Upload the same file again and orders 1001 and 1003 now show "already exists in the database", so no rows are ready.
11. What approval does, and which approval policy to choose
When the user approves, the handler loads the preview from the cache, keeps only ready rows, rechecks their IDs in SQL Server, converts them to Order entities, and calls SaveChangesAsync once. No order is added to EF Core before this point, so previewing never writes business data.
Why one SaveChangesAsync call matters
With a relational provider such as SQL Server, EF Core wraps the changes of a single SaveChanges call in a transaction by default. If one insert fails, the whole save rolls back, so you never get a half-saved batch. You don't need a manual transaction around one call. You'd add an explicit transaction only when several saves or other database operations must succeed or fail together. The official EF Core transaction documentation covers the details.
Note what "atomic" means here. It applies to the accepted rows only. It doesn't mean every row in the file had to be valid.
Partial import or whole-file validation?
| Policy | Approval condition | Typical use |
|---|---|---|
| Partial-valid-row import | At least one row is ready. Only ready rows are saved. | Independent orders or contacts that can be imported separately. |
| Whole-file validation | Every row must be valid before anything is saved. | A file that represents one complete submission or reconciled batch. |
For whole-file validation, add this check in OnPostApproveAsync right after PreviewId = previewId;. Also hide the approve button in the page unless Model.AttentionCount is zero.
if (rows.Any(row => !row.IsReady))
{
ModelState.AddModelError(
string.Empty,
"The file contains invalid rows. Correct every error before approving the import.");
return Page();
}
12. Security and reliability considerations
A CSV import takes outside data and can create many records, so treat it as a controlled ingestion feature rather than a plain file upload.
Validate the file on the server
- Set a maximum request size and file size, and reject empty uploads.
- Check the extension, but don't treat the extension or the browser's content type as proof the contents are safe.
- Parse the file and validate its real structure and values.
- Never use the uploaded file name as a storage path. This sample doesn't save the file to disk at all.
- Don't show users stack traces, local paths, connection strings, or raw database exception messages.
Microsoft's ASP.NET Core file upload guidance explains buffering, streaming, size limits, and other upload risks.
Protect the preview data
The preview is untrusted input and may hold personal or business data. This sample requires sign-in and scopes the cache key by user and preview ID. Never accept parsed rows from the browser as authoritative, because a user could edit hidden fields.
The in-memory cache works for a small single-server demo. With several servers, a preview created on one may not exist on another. Use a shared distributed cache, or store the batch and its rows in staging tables with expiry and cleanup rules.
Keep the database as the final authority
The application-level duplicate check exists to give the user a good message before approval. The unique index is what actually guarantees uniqueness. See Microsoft's EF Core index documentation for the configuration options.
Retries and large files
For production imports where browsers or workers may retry requests, store a durable import-batch record with a unique batch ID and a status such as Pending, Processing, Completed, or Failed. Then you can tell whether a request already committed when a response was lost.
This example is deliberately a small-file design: 2 MB, 10,000 rows, and a preview in memory. For much larger files, use a streaming upload or background job, write parsed rows to staging tables, and show a paginated preview.
13. Common CSV import problems and how to fix them
| Problem | Likely cause | Fix |
|---|---|---|
| "The header must be…" error. | The column names or their order differ from the expected header, or the header checkbox is wrong. | Match Id,CustomerName,Item,Qty,Price,Amount exactly, or clear the checkbox if the file has no header. |
| The whole preview is rejected as malformed. | Broken quoting, or a field longer than 4,096 characters. | Check delimiters and escaped quotes. A file-level error means nothing is shown, which is safer than a partial parse. |
| Numbers are rejected even though they look valid. | The file uses a different decimal or thousands separator than the invariant culture. | Define one accepted number format and parse consistently. |
| Every row is marked as already existing. | The CSV ID is compared with the wrong column, or those orders were already imported. | Confirm the CSV Id maps to ExternalOrderId, not the generated primary key. |
| The migration fails when creating the unique index. | The table already contains duplicate external IDs. | Find and resolve the duplicates, then apply the migration again. |
| The preview expires before approval. | The 15-minute cache entry expired or the app restarted. | Upload the file again. For a durable workflow, use a shared cache or staging tables. |
| The save fails even though the preview showed no duplicates. | Another request inserted the same ID in between, or a different database constraint failed. | Keep the unique index, log the server-side exception, and generate a fresh preview. |
14. Key takeaways
- Keep parsing and saving in separate stages. Uploading a file should never insert business records by itself.
- Collect errors per record so users can fix several problems from one preview.
- Check duplicates inside the file and against the database, and check the database again at approval time.
- Use a unique index so uniqueness holds under concurrent requests.
- Save accepted rows with a single
SaveChangesAsynccall when the batch must be atomic. - Never trust row data posted back from the browser. Look the preview up on the server.
- Move to staging tables and background processing when files get large or the app runs on several servers.
Frequently asked questions
Should CSV validation stop at the first invalid row?
Not in a user-facing preview. Collect each row's errors and keep parsing. The exception is a structural problem, like broken quoting, that makes parsing unreliable. In that case reject the file instead of showing an incomplete result.
How can I detect duplicates inside a CSV file?
Group the parsed IDs and find groups with more than one row. This tutorial flags every row in such a group. You could instead keep the first occurrence, but if you do, tell the user clearly.
Does EF Core SaveChangesAsync automatically use a transaction?
For relational providers like SQL Server, yes. All changes in one call are saved together or rolled back together. You need an explicit transaction only when the work spans several saves or other database operations.
Is checking duplicates before SaveChangesAsync enough?
No. Another request can insert the same ID after your check and before your save. Use the application check for friendly feedback, a unique index for enforcement, and handle DbUpdateException in the approval handler.
Can this handle very large CSV files?
Not as written. It buffers the upload, keeps every preview row in memory, and uses a local cache. Large imports need streaming or background processing, staging tables, and a paginated preview.
Conclusion
A reliable CSV import lets users see exactly what will be saved. Row-level errors, duplicate detection, a preview, and explicit approval make imports easier to review and to troubleshoot.
Keep the final uniqueness rule in SQL Server and save accepted rows as one atomic EF Core batch. The in-memory preview here is a good starting point for small imports. Larger or multi-server systems should move previews into staging tables and track imports with durable batch records.
