DEV Community

Arash Zand
Arash Zand

Posted on • Originally published at Medium

Understanding How Normalizing Tables Can Affect Performance in C# .NET

In software development, we're always chasing that sweet spot of building apps that run smoothly and perform well. When it comes to relational databases, one big decision keeps coming up: normalization. It's a technique that tidies up a database, cutting down on duplicate data and keeping everything consistent.

But here's the catch — while normalization makes your data clean and organized, it can throw a wrench in performance, especially when you're dealing with large datasets or complex queries. In this article, I'll dive into how normalizing tables impacts C# .NET apps running on PostgreSQL or SQL Server, and share practical ways to stay fast despite the trade-offs.

Table of Contents

  1. The Basics of Database Normalization
  2. How Normalization Messes with Performance
  3. Normalization Tricks in SQL Databases
  4. When Normalization Starts Slowing You Down
  5. Tips to Speed Up Normalized Databases
  6. A Real-Life Example: Balancing Normalization and Performance
  7. Keeping an Eye on Performance in Production
  8. Best Practices I've Picked Up Along the Way
  9. Next-Level Optimization for Normalized Databases
  10. Testing and Benchmarking Performance
  11. Applying This Stuff in a C# .NET CRM Project
  12. Tuning Up a CRM System
  13. Wrapping It Up

1. The Basics of Database Normalization

What's Database Normalization All About?

Database normalization splits data into separate tables to cut down on repeats and keep things consistent. The goals:

  • Ditch the duplicates — no more storing the same data in multiple places
  • Stop anomalies — updates, inserts, and deletes stay clean
  • Keep things consistent — data stays accurate across the entire database

Breaking It Down: Normal Forms (1NF, 2NF, 3NF, BCNF)

  • First Normal Form (1NF): Every column has simple, single values — no lists or groups crammed into one row
  • Second Normal Form (2NF): All non-key columns fully depend on the primary key, not just part of it
  • Third Normal Form (3NF): Non-key fields don't depend on other non-key fields — everything ties straight to the primary key
  • Boyce-Codd Normal Form (BCNF): Every "determiner" must be a candidate key — the strictest form

Pros and Cons

✅ Data integrity, less storage, cleaner updates

❌ Query complexity, join overhead, more disk I/O


2. How Normalization Messes with Performance

When you go all-in on normalization, performance starts to feel the squeeze.

The Join Problem

Normalized databases spread data across tables — Users, UserProfiles, UserOrders. To get the full picture you're stuck joining them back together:

SELECT u.UserName, p.ProfilePicture, o.OrderDate  
FROM Users u  
JOIN UserProfiles p ON u.UserID = p.UserID  
JOIN UserOrders o ON u.UserID = o.UserID  
WHERE u.UserID = 123;
Enter fullscreen mode Exit fullscreen mode

If those tables grow large or indexes aren't on point, this query starts moving like molasses.

Disk I/O Woes

More tables means more hopping around. Instead of one Users table with everything, your data is spread across Users, UserProfiles, and UserOrders. A single query might poke around all three, piling up disk reads. Without solid indexes: hello, full table scans.

Caching and Indexing as Lifelines

Stash frequently accessed data — like user profiles — in memory, and you're not pinging the database constantly. Indexes make lookups fast, but too many will slow down your writes. It's about finding the sweet spot.


3. Normalization in SQL Databases

PostgreSQL Example

-- Step 1: Users (1NF)
CREATE TABLE Users (
  UserID SERIAL PRIMARY KEY,
  UserName VARCHAR(50) NOT NULL,
  Email VARCHAR(100) UNIQUE NOT NULL
);

-- Step 2: UserProfiles (2NF)
CREATE TABLE UserProfiles (
  ProfileID SERIAL PRIMARY KEY,
  UserID INT REFERENCES Users(UserID),
  ProfilePicture VARCHAR(255),
  Bio TEXT
);

-- Step 3: UserOrders (3NF)
CREATE TABLE UserOrders (
  OrderID SERIAL PRIMARY KEY,
  UserID INT REFERENCES Users(UserID),
  OrderDate DATE,
  TotalAmount DECIMAL(10, 2)
);
Enter fullscreen mode Exit fullscreen mode

SQL Server Equivalent

Same structure, different syntax — IDENTITY instead of SERIAL, NVARCHAR instead of VARCHAR, FOREIGN KEY explicitly declared.


4. When Normalization Starts Slowing You Down

  • Join overload — juggling many tables tanks performance at scale
  • Real-time pressure — all those joins feel like a traffic jam when sub-second response is required
  • Over-splitting — breaking a simple entity into a dozen tiny tables creates unnecessary complexity

5. Tips to Speed Up Normalized Databases

Smart Indexing

Add indexes on columns you join or filter on frequently — like UserID. Don't over-index or your inserts and updates will crawl.

Denormalize Where It Fits

Merge tables or add duplicate columns to skip expensive joins. For read-heavy workloads, this is a significant win.

Async Queries in C# .NET

public async Task<UserDetails> GetUserDetailsAsync(int userId)
{
    return await _context.Users
        .Where(u => u.UserID == userId)
        .Include(u => u.UserProfile)
        .Include(u => u.UserOrders)
        .FirstOrDefaultAsync();
}
Enter fullscreen mode Exit fullscreen mode

6. A Real-Life Example: Balancing Normalization and Performance

The Slow Query

SELECT u.UserName, u.Email, o.OrderDate, p.ProductName, od.Quantity, od.Price
FROM Users u
JOIN UserOrders o ON u.UserID = o.UserID
JOIN OrderDetails od ON o.OrderID = od.OrderID
JOIN Products p ON od.ProductID = p.ProductID
WHERE u.UserID = 123;
Enter fullscreen mode Exit fullscreen mode

Before optimization:

  • Query time: 2.5 seconds
  • Disk I/O: 15,000 reads
  • CPU: 85%
  • App response: 3 seconds

What we did:

  1. Added indexes on UserID, OrderID, ProductID
  2. Cached user profiles and order details in memory
  3. Denormalized ProductNames and TotalAmount into UserOrders
  4. Rewrote the query to use the denormalized columns — 2 joins instead of 4

After optimization:

  • Query time: 0.5 seconds
  • Disk I/O: 2,500 reads
  • CPU: 30%
  • App response: 1 second

7. Keeping an Eye on Performance in Production

PostgreSQL EXPLAIN ANALYZE

EXPLAIN ANALYZE
SELECT u.UserName, u.Email, o.OrderDate, p.ProductName, od.Quantity, od.Price
FROM Users u
JOIN UserOrders o ON u.UserID = o.UserID
JOIN OrderDetails od ON o.OrderID = od.OrderID
JOIN Products p ON od.ProductID = p.ProductID
WHERE u.UserID = 123;
Enter fullscreen mode Exit fullscreen mode

Shows you the execution plan, row estimates, and where time is being spent.

Application Insights in C# .NET

public void TrackQueryPerformance(string queryName, TimeSpan duration)
{
    var telemetry = new DependencyTelemetry
    {
        Type = "SQL",
        Target = "PostgreSQL",
        Data = queryName,
        Duration = duration
    };
    _telemetryClient.TrackDependency(telemetry);
}
Enter fullscreen mode Exit fullscreen mode

Set alerts for queries exceeding a threshold — catch regressions before users notice.


8. Best Practices

  • Don't over-normalize — if speed matters, denormalize selectively
  • Index the join columnsUserID, OrderID, ProductID are your starting points
  • Cache read-heavy data — user profiles, product catalogs, anything that doesn't change constantly
  • Think about partitioning — for massive tables, partition by date or region to scope queries

Normalize when: data integrity is the priority, you're optimizing for storage, updates need to stay consistent.

Denormalize when: reads dominate, joins are killing you at scale, query complexity is spiraling.


9. Next-Level Optimization

Materialized Views

Pre-compute expensive joins and store the results:

CREATE MATERIALIZED VIEW UserOrderDetails AS
SELECT u.UserID, u.UserName, o.OrderID, o.OrderDate, p.ProductName, od.Quantity, od.Price
FROM Users u
JOIN UserOrders o ON u.UserID = o.UserID
JOIN OrderDetails od ON o.OrderID = od.OrderID
JOIN Products p ON od.ProductID = p.ProductID;

-- Query it directly
SELECT * FROM UserOrderDetails WHERE UserID = 123;

-- Refresh when needed
REFRESH MATERIALIZED VIEW UserOrderDetails;
Enter fullscreen mode Exit fullscreen mode

Partitioning

CREATE TABLE UserOrders (
  OrderID SERIAL PRIMARY KEY,
  UserID INT,
  OrderDate DATE,
  TotalAmount DECIMAL(10, 2)
) PARTITION BY RANGE (OrderDate);

CREATE TABLE UserOrders_2024 PARTITION OF UserOrders
  FOR VALUES FROM ('2024-01-01') TO ('2024-12-31');
Enter fullscreen mode Exit fullscreen mode

Queries scoped to 2024 skip all historical data entirely.

Redis Caching

public async Task<string> GetUserProfileAsync(int userId)
{
    string cacheKey = $"user:{userId}:profile";
    var cached = await _cache.StringGetAsync(cacheKey);

    if (cached.HasValue) return cached;

    var profile = GetUserProfileFromDatabase(userId);
    await _cache.StringSetAsync(cacheKey, profile);
    return profile;
}
Enter fullscreen mode Exit fullscreen mode

Bulk Inserts with Dapper

connection.Execute(
    "INSERT INTO UserOrders (UserID, OrderDate, TotalAmount) VALUES (@UserID, @OrderDate, @TotalAmount)",
    orders
);
Enter fullscreen mode Exit fullscreen mode

10. Testing and Benchmarking with BenchmarkDotNet

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class DatabaseQueryBenchmark
{
    [Benchmark]
    public void GetUserOrders()
    {
        var result = GetUserOrdersFromDatabase(123);
    }
}

public class Program
{
    public static void Main(string[] args) =>
        BenchmarkRunner.Run<DatabaseQueryBenchmark>();
}
Enter fullscreen mode Exit fullscreen mode

Run against production-like data volumes to validate that optimizations hold under real load.


11. & 12. CRM Case Study

A C# .NET CRM with customers, contacts, opportunities, products, and orders. Heavy normalization worked fine at small scale — then the data grew.

Before optimization:

  • Query time: 4 seconds
  • Disk I/O: 20,000 reads
  • CPU: 90%
  • App response: 5 seconds

Fixes applied:

  1. Indexes on CustomerID, OrderID, ProductID
  2. Denormalized ProductName and Quantity into the Orders table — eliminated 2 joins from the core query
  3. Redis caching for customer profiles and sales opportunities
  4. Materialized view for the customer sales report
  5. Async queries throughout with Dapper/Npgsql

After optimization:

  • Query time cut by 80%+
  • CPU load under 30%
  • App response under 1 second

The materialized view for reporting deserves a special mention:

CREATE MATERIALIZED VIEW CustomerSalesReport AS
SELECT c.CustomerID, c.CustomerName, SUM(o.TotalAmount) AS TotalSales
FROM Customers c
JOIN Orders o ON c.CustomerID = o.CustomerID
GROUP BY c.CustomerID, c.CustomerName;
Enter fullscreen mode Exit fullscreen mode

Reports that previously ran aggregations on the fly now return instantly.


13. Wrapping It Up

Normalization is essential for data integrity and clean architecture — but as systems scale, the join overhead becomes real. The trick is knowing when to stay normalized and when to selectively denormalize, and pairing that with the right mix of indexing, caching, materialized views, and async patterns.

Tools like PostgreSQL's EXPLAIN ANALYZE, Application Insights, and BenchmarkDotNet give you the visibility to make those decisions based on evidence rather than guesswork.

The sweet spot: normalization for correctness, selective denormalization and caching for speed. Neither at the expense of the other.


Originally published on Medium.

Top comments (0)