Back to blog
Backend Systemsintermediate

Raw SQL, Dapper & ORM Comparison in .NET

When to use EF Core, when to drop to raw SQL, and how to use Dapper for high-performance queries. Covers ADO.NET, Dapper, Dapper.Contrib, multi-mapping, stored procedures, and the ORM decision framework.

LearnixoApril 14, 20268 min read
.NETDapperEF CoreRaw SQLORMC#SQL ServerPerformance
Share:š•

Why Raw SQL Exists Alongside EF Core

EF Core is excellent for standard CRUD — inserts, updates, deletes, and most queries. But there are cases where the generated SQL isn't what you need:

  • Complex reporting queries with window functions, CTEs, and multi-level aggregations
  • Bulk operations — updating 100,000 rows via EF Core is slow (change tracking overhead)
  • Performance-critical hot paths — shaving 5ms off a query that runs 10,000 times/day matters
  • Stored procedures your DBA has already optimised
  • Raw aggregations where LINQ can't express the query cleanly

The answer isn't "use one or the other" — it's use both. EF Core for day-to-day CRUD; Dapper (or raw ADO.NET) for complex reads and bulk writes.


ADO.NET — The Foundation

Everything else in .NET data access builds on ADO.NET. Knowing it makes you understand what ORM abstractions are hiding:

C#
// Raw ADO.NET — maximum control, maximum verbosity
public async Task<List<OrderSummary>> GetOrderSummariesAsync(int customerId)
{
    await using var conn = new SqlConnection(_connectionString);
    await conn.OpenAsync();

    await using var cmd = conn.CreateCommand();
    cmd.CommandText = """
        SELECT o.Id, o.ReferenceNumber, o.TotalAmount, c.Name AS CustomerName
        FROM Orders o
        INNER JOIN Customers c ON c.Id = o.CustomerId
        WHERE o.CustomerId = @CustomerId
        ORDER BY o.CreatedAt DESC
        """;

    cmd.Parameters.AddWithValue("@CustomerId", customerId);

    var results = new List<OrderSummary>();
    await using var reader = await cmd.ExecuteReaderAsync();

    while (await reader.ReadAsync())
    {
        results.Add(new OrderSummary(
            Id:           reader.GetInt32(0),
            Reference:    reader.GetString(1),
            Total:        reader.GetDecimal(2),
            CustomerName: reader.GetString(3)));
    }

    return results;
}

This works but it's verbose. Dapper wraps ADO.NET and eliminates the mapping boilerplate.


Dapper

Dapper is a micro-ORM — it maps SQL results to C# objects with minimal overhead. It runs on top of IDbConnection (the ADO.NET interface).

Bash
dotnet add package Dapper
C#
// Register the connection factory
builder.Services.AddScoped<IDbConnection>(sp =>
    new SqlConnection(sp.GetRequiredService<IConfiguration>()
        .GetConnectionString("DefaultConnection")));

Basic queries

C#
// Query<T> — map a SELECT to a list of objects
public async Task<IEnumerable<Order>> GetOrdersAsync(int customerId)
{
    const string sql = """
        SELECT Id, ReferenceNumber, TotalAmount, Status, CreatedAt
        FROM Orders
        WHERE CustomerId = @CustomerId
        ORDER BY CreatedAt DESC
        """;

    return await _db.QueryAsync<Order>(sql, new { CustomerId = customerId });
}

// QueryFirstOrDefaultAsync — single result
public async Task<Order?> GetByIdAsync(int id)
{
    const string sql = "SELECT * FROM Orders WHERE Id = @Id";
    return await _db.QueryFirstOrDefaultAsync<Order>(sql, new { Id = id });
}

// ExecuteAsync — INSERT, UPDATE, DELETE
public async Task<int> UpdateStatusAsync(int orderId, string status)
{
    const string sql = "UPDATE Orders SET Status = @Status WHERE Id = @Id";
    return await _db.ExecuteAsync(sql, new { Id = orderId, Status = status });
}

// ExecuteScalarAsync — single value result
public async Task<int> GetOrderCountAsync(int customerId)
{
    const string sql = "SELECT COUNT(*) FROM Orders WHERE CustomerId = @CustomerId";
    return await _db.ExecuteScalarAsync<int>(sql, new { CustomerId = customerId });
}

Multi-mapping (JOINs → multiple objects)

C#
// Map a JOIN result to nested objects
public async Task<IEnumerable<OrderWithCustomer>> GetOrdersWithCustomersAsync()
{
    const string sql = """
        SELECT o.Id, o.ReferenceNumber, o.TotalAmount,
               c.Id, c.Name, c.Email
        FROM Orders o
        INNER JOIN Customers c ON c.Id = o.CustomerId
        """;

    return await _db.QueryAsync<Order, Customer, OrderWithCustomer>(
        sql,
        (order, customer) => new OrderWithCustomer(order, customer),
        splitOn: "Id"   // second "Id" column is where Customer starts
    );
}

public record OrderWithCustomer(Order Order, Customer Customer);

Multi-result sets

C#
// One round-trip, multiple result sets
public async Task<(IEnumerable<Order> Orders, int TotalCount)> GetPagedAsync(
    int page, int pageSize)
{
    const string sql = """
        SELECT * FROM Orders ORDER BY CreatedAt DESC
        OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;

        SELECT COUNT(*) FROM Orders;
        """;

    using var multi = await _db.QueryMultipleAsync(sql,
        new { Offset = (page - 1) * pageSize, PageSize = pageSize });

    var orders = await multi.ReadAsync<Order>();
    var total  = await multi.ReadFirstAsync<int>();

    return (orders, total);
}

Stored procedures

C#
// Call a stored procedure with output parameters
public async Task<int> CreateOrderSpAsync(CreateOrderParams parameters)
{
    var dynamicParams = new DynamicParameters(parameters);
    dynamicParams.Add("@NewOrderId", dbType: DbType.Int32, direction: ParameterDirection.Output);

    await _db.ExecuteAsync(
        "dbo.usp_CreateOrder",
        dynamicParams,
        commandType: CommandType.StoredProcedure);

    return dynamicParams.Get<int>("@NewOrderId");
}

Bulk Operations

EF Core tracks every entity — bulk inserts via AddRange and SaveChanges are slow for large datasets.

C#
// āŒ EF Core bulk insert — 10,000 rows = very slow (change tracker overhead)
_db.Products.AddRange(products);
await _db.SaveChangesAsync();   // fires 10,000 individual INSERT statements

// āœ… Option 1: EF Core Bulk Extensions
dotnet add package EFCore.BulkExtensions
await _db.BulkInsertAsync(products);            // one SQL statement
await _db.BulkUpdateAsync(products);
await _db.BulkDeleteAsync(products);

// āœ… Option 2: SqlBulkCopy (raw ADO.NET) — fastest possible
public async Task BulkInsertOrdersAsync(IEnumerable<Order> orders)
{
    using var dataTable = ToDataTable(orders);
    await using var bulkCopy = new SqlBulkCopy(_connectionString)
    {
        DestinationTableName = "Orders",
        BatchSize            = 1000,
    };
    await bulkCopy.WriteToServerAsync(dataTable);
}

// āœ… Option 3: EF Core ExecuteUpdate/ExecuteDelete (EF 7+) — set-based, no loading
// Update all pending orders older than 7 days to Cancelled — one SQL UPDATE
await _db.Orders
    .Where(o => o.Status == "Pending" && o.CreatedAt < DateTime.UtcNow.AddDays(-7))
    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Cancelled"));

// Delete all archived records — one SQL DELETE
await _db.AuditLogs
    .Where(l => l.CreatedAt < DateTime.UtcNow.AddMonths(-6))
    .ExecuteDeleteAsync();

The Decision Framework: EF Core vs Dapper vs Raw ADO.NET

Use EF Core when:
āœ… Standard CRUD (Create, Read, Update, Delete)
āœ… You want change tracking + SaveChanges
āœ… Relationships and navigation properties (Include)
āœ… Migrations to manage schema
āœ… Most of your queries are simple WHERE + JOIN

Use Dapper when:
āœ… Complex reporting queries (GROUP BY, window functions, CTEs)
āœ… You need full SQL control
āœ… The EF Core-generated SQL isn't performing well
āœ… Stored procedures with output parameters
āœ… You need the absolute minimum overhead on a hot read path

Use Raw ADO.NET when:
āœ… Bulk inserts (SqlBulkCopy)
āœ… Custom connection management
āœ… You're building your own data access library
āœ… You need streaming large result sets without buffering

Mix all three in the same project:
āœ… Use EF Core for commands (write operations)
āœ… Use Dapper for complex query reads
āœ… Use SqlBulkCopy for imports/batch operations

Using Both EF Core and Dapper Together

They can share the same connection. The cleanest approach is to inject both:

C#
// Register EF Core for writes
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

// Register Dapper's IDbConnection for reads
builder.Services.AddScoped<IDbConnection>(_ =>
    new SqlConnection(connectionString));
C#
// Command handler — uses EF Core (change tracking, domain events)
public class ConfirmOrderCommandHandler : IRequestHandler<ConfirmOrderCommand>
{
    private readonly AppDbContext _db;

    public async Task Handle(ConfirmOrderCommand cmd, CancellationToken ct)
    {
        var order = await _db.Orders.FindAsync(cmd.OrderId, ct)
            ?? throw new NotFoundException(cmd.OrderId);

        order.Confirm();               // domain logic, raises events
        await _db.SaveChangesAsync(ct); // persists + dispatches events
    }
}

// Query handler — uses Dapper (no tracking, complex SQL, fast)
public class GetOrderReportQueryHandler : IRequestHandler<GetOrderReportQuery, OrderReportDto>
{
    private readonly IDbConnection _db;

    public async Task<OrderReportDto> Handle(GetOrderReportQuery query, CancellationToken ct)
    {
        const string sql = """
            WITH MonthlyRevenue AS (
                SELECT
                    YEAR(CreatedAt)  AS Year,
                    MONTH(CreatedAt) AS Month,
                    SUM(TotalAmount) AS Revenue,
                    COUNT(*)         AS OrderCount
                FROM Orders
                WHERE Status = 'Confirmed'
                  AND CreatedAt >= @StartDate
                GROUP BY YEAR(CreatedAt), MONTH(CreatedAt)
            )
            SELECT Year, Month, Revenue, OrderCount,
                   SUM(Revenue) OVER (ORDER BY Year, Month) AS CumulativeRevenue
            FROM MonthlyRevenue
            ORDER BY Year, Month
            """;

        var rows = await _db.QueryAsync<MonthlyRevenueRow>(
            sql, new { StartDate = query.StartDate });

        return new OrderReportDto(rows.ToList());
    }
}

Performance Comparison

Benchmarks for a simple SELECT on 1,000 rows (approximate, hardware-dependent):

| Approach | Time | Overhead | |----------|------|---------| | Raw ADO.NET | 0.8ms | Baseline | | Dapper | 1.1ms | +0.3ms mapping | | EF Core AsNoTracking | 2.2ms | +1.4ms expression translation | | EF Core tracked | 4.1ms | +3.3ms change tracking |

The difference is small for a single query. At 5,000 queries/second the difference becomes: 5,500ms (ADO.NET) vs 20,500ms (EF Core tracked). Use AsNoTracking() on all read queries in EF Core — it gets you most of the way to Dapper performance.

For hot read paths, Dapper is the right tool. For everything else, EF Core with AsNoTracking() is fine.


Key Takeaways

  • EF Core for writes and standard CRUD — change tracking, migrations, and domain model integration
  • Dapper for complex reads — raw SQL, window functions, CTEs, stored procedures
  • SqlBulkCopy for bulk inserts — orders of magnitude faster than EF Core's AddRange
  • EF Core ExecuteUpdate/ExecuteDelete (EF 7+) for set-based bulk updates/deletes — no loading required
  • Mix them in the same project — CQRS naturally maps to EF Core for commands, Dapper for queries
  • AsNoTracking() on EF Core read queries closes most of the performance gap with Dapper
  • Never choose an ORM based on religion — choose based on what each query needs

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.