Data Manipulation with LINQ: A Comprehensive Exploration of Performance Enhancement and Implementation Details

Mohamed Hendawy
7 min readJan 20, 2024

Introduction:

Language Integrated Query (LINQ) has transformed the way developers interact with data in the .NET framework, offering a powerful and expressive syntax for querying various data sources. Beyond its readability and conciseness, LINQ introduces performance enhancements that become particularly evident when transitioning from traditional procedural code to its declarative approach. In this article, we’ll thoroughly examine the impact of LINQ on performance through real-world examples, showcasing code before and after adopting LINQ. Additionally, we’ll delve into the inner workings of LINQ to demystify how it achieves these performance gains.

Before diving into LINQ, let’s consider a scenario where we want to filter and manipulate data without using LINQ. We’ll start with a common task: filtering a list of integers and then squaring each number that meets a certain condition.

Traditional Approach without LINQ

List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
List<int> squaredNumbers = new List<int>();

foreach (int num in numbers)
{
if (num % 2 == 0 && num > 5)
{
squaredNumbers.Add(num * num);
}
}

While the above code achieves the desired result, it lacks the elegance and expressiveness that LINQ brings to the table. Now, let’s reimagine the same task using LINQ.

LINQ Approach

List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var squaredNumbers = numbers
.Where(num => num % 2 == 0 && num > 5)
.Select(num => num * num)
.ToList();

Performance Gains through Deferred Execution:

LINQ introduces the concept of deferred execution, which means that the execution of a query is postponed until the actual results are needed. This can lead to significant performance improvements, especially when dealing with large datasets.

Deferred Execution

var query = numbers.Where(num => num > 5).Select(num => num * 2);

foreach (var result in query)
{
Console.WriteLine(result);
}

In this example, the Where and Select operations are deferred until the foreach loop iterates over the results. This deferred execution minimizes unnecessary computations and improves efficiency.

Optimizing with IQueryable for Database Queries:

When working with databases, LINQ can be further optimized using the IQueryable interface. This interface allows the creation of more efficient queries, as they can be translated into optimized SQL queries.

IQueryable for Database Queries

Assume we have a database context dbContext:

var queryable = dbContext.Customers.Where(c => c.Age > 25).OrderBy(c => c.Name);

foreach (var customer in queryable)
{
Console.WriteLine($"{customer.Name}, {customer.Age}");
}

In this example, the LINQ query is translated into a more optimized SQL query when working with Entity Framework, resulting in better performance when retrieving data from a database.

Filtering and Transforming Strings

Consider a scenario where you have a list of names, and you want to filter out names that start with the letter ‘J’ and transform the remaining names to uppercase.

Traditional Approach without LINQ:

List<string> names = new List<string> { "John", "Alice", "Bob", "Jane", "Charlie" };
List<string> filteredAndUppercasedNames = new List<string>();

foreach (string name in names)
{
if (!name.StartsWith("J"))
{
filteredAndUppercasedNames.Add(name.ToUpper());
}
}

LINQ Approach:

var resultNames = names
.Where(name => !name.StartsWith("J"))
.Select(name => name.ToUpper())
.ToList();

Grouping

Let’s say you have a list of products, each with a category and price, and you want to calculate the average price for each category.

Traditional Approach without LINQ:

List<Product> products = GetProducts(); // Assume a method to retrieve products
Dictionary<string, double> averagePrices = new Dictionary<string, double>();
Dictionary<string, int> productCounts = new Dictionary<string, int>();

foreach (Product product in products)
{
if (!averagePrices.ContainsKey(product.Category))
{
averagePrices.Add(product.Category, 0);
productCounts.Add(product.Category, 0);
}

averagePrices[product.Category] += product.Price;
productCounts[product.Category]++;
}

foreach (string category in averagePrices.Keys.ToList())
{
averagePrices[category] /= productCounts[category];
}

LINQ Approach:

var averagePrices = products
.GroupBy(product => product.Category)
.ToDictionary(
group => group.Key,
group => group.Average(product => product.Price)
);

In this example, LINQ’s GroupBy and Average methods streamline the grouping and aggregation process.

Joining Data from Multiple Sources

Suppose you have two lists — one containing customers and another containing orders. You want to retrieve a list of customers along with their total order amounts.

Traditional Approach without LINQ:

List<Customer> customers = GetCustomers(); // Assume a method to retrieve customers
List<Order> orders = GetOrders(); // Assume a method to retrieve orders
List<(Customer, double)> customerOrderTotals = new List<(Customer, double)>();

foreach (Customer customer in customers)
{
double totalAmount = 0;

foreach (Order order in orders.Where(o => o.CustomerId == customer.Id))
{
totalAmount += order.Amount;
}

customerOrderTotals.Add((customer, totalAmount));
}

LINQ Approach:

var customerOrderTotals = customers
.Join(orders, customer => customer.Id, order => order.CustomerId, (customer, order) => new { customer, order.Amount })
.GroupBy(result => result.customer)
.Select(group => (group.Key, group.Sum(result => result.Amount)))
.ToList();

In this example, LINQ’s Join and GroupBy operations simplify the process of combining data from multiple sources.

Combining Filtering, Sorting, and Projection

Assume you have a list of employees, each with a name, salary, and department. You want to find the names of employees earning more than $50,000, sorted by salary in descending order.

List<Employee> employees = GetEmployees(); // Assume a method to retrieve employees
List<string> highPaidEmployeeNames = new List<string>();

foreach (Employee employee in employees)
{
if (employee.Salary > 50000)
{
highPaidEmployeeNames.Add(employee.Name);
}
}

highPaidEmployeeNames.Sort((name1, name2) => employees.First(e => e.Name == name2).Salary.CompareTo(employees.First(e => e.Name == name1).Salary));

LINQ Approach:

var resultNames = employees
.Where(employee => employee.Salary > 50000)
.OrderByDescending(employee => employee.Salary)
.Select(employee => employee.Name)
.ToList();

In this example, LINQ allows for a more concise expression of filtering, sorting, and projection, making the code easier to read and maintain.

Joining and Grouping Data with LINQ

Let’s consider a scenario where you have a list of students and a list of courses they have taken, and you want to find the average grade for each student.

Traditional Approach without LINQ:

List<Student> students = GetStudents(); // Assume a method to retrieve students
List<Course> courses = GetCourses(); // Assume a method to retrieve courses
Dictionary<string, List<int>> studentGrades = new Dictionary<string, List<int>>();

foreach (Student student in students)
{
List<int> grades = new List<int>();

foreach (Course course in courses.Where(c => c.StudentId == student.Id))
{
grades.Add(course.Grade);
}

studentGrades.Add(student.Name, grades);
}

LINQ Approach:

var studentGrades = students
.GroupJoin(courses, student => student.Id, course => course.StudentId, (student, courseGroup) => new
{
student.Name,
AverageGrade = courseGroup.Any() ? courseGroup.Average(course => course.Grade) : 0
})
.ToDictionary(result => result.Name, result => result.AverageGrade);

In this example, LINQ’s GroupJoin operation simplifies the process of grouping students and calculating the average grade.

Performing Set Operations with LINQ

Consider a scenario where you have two lists of integers, and you want to find the common elements and the elements unique to each list.

Traditional Approach without LINQ:

List<int> list1 = new List<int> { 1, 2, 3, 4, 5 };
List<int> list2 = new List<int> { 3, 4, 5, 6, 7 };

List<int> commonElements = new List<int>();
List<int> uniqueToFirstList = new List<int>();
List<int> uniqueToSecondList = new List<int>();

foreach (int num in list1)
{
if (list2.Contains(num))
{
commonElements.Add(num);
}
else
{
uniqueToFirstList.Add(num);
}
}

foreach (int num in list2.Where(num => !list1.Contains(num)))
{
uniqueToSecondList.Add(num);
}

LINQ Approach:

var commonElements = list1.Intersect(list2).ToList();
var uniqueToFirstList = list1.Except(list2).ToList();
var uniqueToSecondList = list2.Except(list1).ToList();

LINQ’s Intersect, Except, and Union methods simplify set operations, making the code more concise and readable.

Aggregation Functions

LINQ provides aggregation functions like Sum, Average, Min, Max, and Count that can be used to perform calculations on numeric data.

Example:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
var sum = numbers.Sum();
var average = numbers.Average();
var min = numbers.Min();
var max = numbers.Max();
var count = numbers.Count();

Partitioning Operators

LINQ provides operators like Take and Skip for partitioning data, allowing you to take a specific number of elements or skip a certain number of elements.

Example:

var firstThree = numbers.Take(3).ToList(); // Takes the first three elements
var skipTwo = numbers.Skip(2).ToList(); // Skips the first two elements

Set Operations

Apart from Intersect, Except, and Union, LINQ also includes Distinct for obtaining distinct elements from a collection.

Example:

var distinctNumbers = numbers.Distinct().ToList(); // Removes duplicates

Concurrency and Parallel LINQ (PLINQ)

PLINQ introduces parallelism into LINQ queries, allowing them to be executed concurrently for performance improvement.

  • PLINQ extends LINQ to provide parallel processing capabilities.
  • It introduces parallelism to the query execution, allowing multiple elements of a sequence to be processed simultaneously on multiple threads.

Consider a scenario where you have a large collection of numbers, and you want to perform an intensive operation on each element in parallel.

Example:

List<int> numbers = Enumerable.Range(1, 1000000);

var result = numbers.AsParallel()
.Select(num => PerformIntensiveOperation(num))
.ToList();

In this example, PerformIntensiveOperation is a hypothetical method representing a computationally intensive task. PLINQ parallelizes the execution of this task across multiple threads, potentially leading to improved performance.

Another example:
Suppose you have a large dataset of images, and you need to apply a computationally intensive image processing operation to each image. This operation could be resizing the images, applying filters, or any other CPU-intensive task.

Traditional Approach without PLINQ:

List<Image> images = GetImages(); // Assume a method to retrieve a large collection of images
List<Image> processedImages = new List<Image>();

foreach (Image image in images)
{
Image processedImage = ProcessImage(image); // Some computationally intensive operation
processedImages.Add(processedImage);
}

In this traditional approach, the image processing operation is performed sequentially, leading to potentially slow execution, especially with a large number of images.

Using PLINQ for Parallel Image Processing:

List<Image> images = GetImages();

var processedImages = images.AsParallel()
.Select(image => ProcessImage(image)) // Parallel processing of each image
.ToList();

In this PLINQ approach, the AsParallel() method is used to parallelize the image processing operation. Each image is processed concurrently on multiple threads, taking advantage of the available CPU cores.

--

--