LyraLearn AI Learning Platform
Exams
← Module 4 Β· SARGability and Query Anti-Patterns

Implicit Conversion: The .NET One

This lesson covers a specific, extremely common problem in ASP.NET applications: the C# looks correct, the SQL looks correct, the index exists β€” and every query scans the table anyway.

The mechanism

SQL Server has a fixed data type precedence order. When comparing two values of different types, the lower-precedence one is converted to the higher. NVARCHAR has higher precedence than VARCHAR.

So if a column is VARCHAR(20) and the parameter is NVARCHAR(20), SQL Server cannot convert the parameter down. It converts the column up β€” every row of it β€” and then compares. Converting the column means the predicate is now a function on the column, which means it is not SARGable, which means a scan.

The plan calls this CONVERT_IMPLICIT and, on the operator, shows the warning: "Type conversion in expression may affect CardinalityEstimate in query plan choice."

Why .NET produces it by default

Microsoft.Data.SqlClient (and the older System.Data.SqlClient) maps System.String to NVARCHAR unless told otherwise. That is a sensible default β€” .NET strings are Unicode. It is also why an application against a VARCHAR schema scans on every string lookup.

// This sends @CustomerCode as NVARCHAR(4000).
// If dbo.Customer.CustomerCode is VARCHAR(20), every execution scans the table.
cmd.Parameters.AddWithValue("@CustomerCode", code);

AddWithValue compounds it by also inferring the length from the value, so "ABC" becomes NVARCHAR(3) and "ABCD" becomes NVARCHAR(4) β€” different parameter signatures, different cache entries, plan cache bloat on top of the scan.

Entity Framework Core has the same default: a string property maps to nvarchar(max) unless the model says otherwise, and a LINQ comparison against a varchar column produces the same conversion.

Spotting it in a plan

Look for:

Finding it across the whole application at once

This sweep of the plan cache is the highest-value single query in the module. It finds every cached plan containing a conversion that affected plan choice:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;   -- reading cached plan XML, not user data

WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT TOP (50)
       stmt.value('(@StatementText)[1]', 'nvarchar(max)') AS statement_text,
       t.value('(ScalarOperator/Identifier/ColumnReference/@Schema)[1]','sysname') AS schema_name,
       t.value('(ScalarOperator/Identifier/ColumnReference/@Table)[1]','sysname')  AS table_name,
       t.value('(ScalarOperator/Identifier/ColumnReference/@Column)[1]','sysname') AS column_name,
       stmt.value('(@StatementSubTreeCost)[1]','float')   AS subtree_cost,
       qs.execution_count,
       qs.total_elapsed_time / NULLIF(qs.execution_count,0) / 1000 AS avg_ms
FROM sys.dm_exec_cached_plans AS cp
CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) AS qp
CROSS APPLY qp.query_plan.nodes('//StmtSimple') AS s(stmt)
CROSS APPLY stmt.nodes('.//ScalarOperator[@ScalarString[contains(.,"CONVERT_IMPLICIT")]]') AS c(t)
LEFT JOIN sys.dm_exec_query_stats AS qs ON qs.plan_handle = cp.plan_handle
WHERE stmt.exist('@StatementText[contains(.,"SELECT")]') = 1
ORDER BY qs.total_elapsed_time DESC;

The XML parsing makes this heavy on a large plan cache β€” run it during a quiet period. The simpler string match is cheaper and catches most of them:

SELECT TOP (25)
       SUBSTRING(t.text, 1, 400) AS query_snippet,
       qs.execution_count,
       qs.total_elapsed_time / NULLIF(qs.execution_count,0) / 1000 AS avg_ms,
       p.query_plan
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle)    AS t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS p
WHERE CAST(p.query_plan AS nvarchar(max)) LIKE '%PlanAffectingConvert%'
ORDER BY qs.total_elapsed_time DESC;

Finding the schema-side risk directly

You can also find candidate columns before anything is slow β€” every VARCHAR/CHAR column in the database that an application might compare a .NET string against:

SELECT  OBJECT_SCHEMA_NAME(c.object_id) AS schema_name,
        OBJECT_NAME(c.object_id)        AS table_name,
        c.name                          AS column_name,
        ty.name                         AS data_type,
        c.max_length,
        (SELECT SUM(p.rows) FROM sys.partitions p
          WHERE p.object_id = c.object_id AND p.index_id IN (0,1)) AS approx_rows
FROM sys.columns AS c
JOIN sys.types   AS ty ON ty.user_type_id = c.user_type_id
WHERE ty.name IN ('varchar','char')
  AND OBJECTPROPERTY(c.object_id,'IsUserTable') = 1
  AND EXISTS (SELECT 1 FROM sys.index_columns ic
               WHERE ic.object_id = c.object_id AND ic.column_id = c.column_id)
ORDER BY approx_rows DESC;

Indexed varchar columns on large tables are exactly where this bites.

Reproducing it

DROP TABLE IF EXISTS #Customer;
CREATE TABLE #Customer (
    CustomerId   int IDENTITY(1,1) PRIMARY KEY CLUSTERED,
    CustomerCode varchar(20)  NOT NULL,      -- VARCHAR, as legacy schemas often are
    CustomerName nvarchar(200) NOT NULL
);

INSERT INTO #Customer (CustomerCode, CustomerName)
SELECT TOP (100000)
       'C' + RIGHT('0000000' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS varchar(10)), 7),
       N'Customer ' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS nvarchar(10))
FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b;

CREATE UNIQUE NONCLUSTERED INDEX IX_Customer_Code ON #Customer (CustomerCode) INCLUDE (CustomerName);

SET STATISTICS IO ON;

-- What .NET sends by default: NVARCHAR parameter -> converts the COLUMN -> scan
DECLARE @codeN nvarchar(20) = N'C0004242';
SELECT CustomerId, CustomerName FROM #Customer WHERE CustomerCode = @codeN;

-- Matching type -> seek
DECLARE @codeV varchar(20) = 'C0004242';
SELECT CustomerId, CustomerName FROM #Customer WHERE CustomerCode = @codeV;

SET STATISTICS IO OFF;

Run both with the actual plan on. The first shows a scan with the conversion warning; the second is a one-row seek. On 100,000 rows the difference is already large; on a fact table it is the whole report.

The fixes, in order of preference

1. Fix the .NET parameter type. No schema change, no downtime, one line.

// ADO.NET: state the type and the length explicitly
var p = cmd.Parameters.Add("@CustomerCode", SqlDbType.VarChar, 20);
p.Value = code;

// Dapper
conn.Query<Customer>(sql, new { CustomerCode = new DbString {
    Value = code, IsAnsi = true, IsFixedLength = false, Length = 20 } });

The Dapper IsAnsi = true flag is the direct expression of "send this as VARCHAR, not NVARCHAR."

2. Fix the EF Core mapping. IsUnicode(false) makes EF send varchar:

modelBuilder.Entity<Customer>()
    .Property(c => c.CustomerCode)
    .HasColumnType("varchar(20)")   // or .IsUnicode(false).HasMaxLength(20)
    .IsUnicode(false)
    .HasMaxLength(20);

Setting HasMaxLength matters as well as the type: without it EF may send varchar(max), which avoids the Unicode conversion but can still cause plan issues.

3. Align the column type. If the column should have been NVARCHAR all along, change it β€” but this rewrites the table and every index on it, needs a maintenance window, and doubles the storage for that column. It is the right call when the data genuinely needs Unicode, not merely to fix a plan.

4. Cast in the SQL β€” WHERE CustomerCode = CAST(@code AS varchar(20)). Works, and it is the right choice when you control the SQL but not the caller. Note the direction: cast the parameter, never the column.

The same issue between columns

Implicit conversion also appears in JOIN conditions when two tables disagree on type β€” a varchar key joined to an nvarchar key, or an int joined to a bigint. The join column gets converted and loses its seek, which turns an efficient nested-loops join into a scan-and-hash.

-- Find join-key type mismatches across foreign key relationships
SELECT  OBJECT_NAME(fk.parent_object_id)     AS child_table,
        pc.name                              AS child_column,
        pt.name + '(' + CAST(pc.max_length AS varchar(10)) + ')'  AS child_type,
        OBJECT_NAME(fk.referenced_object_id) AS parent_table,
        rc.name                              AS parent_column,
        rt.name + '(' + CAST(rc.max_length AS varchar(10)) + ')'  AS parent_type
FROM sys.foreign_key_columns AS fkc
JOIN sys.foreign_keys AS fk ON fk.object_id = fkc.constraint_object_id
JOIN sys.columns AS pc ON pc.object_id = fkc.parent_object_id     AND pc.column_id = fkc.parent_column_id
JOIN sys.columns AS rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
JOIN sys.types   AS pt ON pt.user_type_id = pc.user_type_id
JOIN sys.types   AS rt ON rt.user_type_id = rc.user_type_id
WHERE pt.name <> rt.name OR pc.max_length <> rc.max_length;

In practice

SqlClient maps System.String to NVARCHAR by default, and NVARCHAR has higher type precedence than VARCHAR β€” so comparing to a VARCHAR column converts the column, not the parameter, and that kills the seek. The C# looks fine and the index exists, so it is easy to miss. The plan shows CONVERT_IMPLICIT and a cardinality warning. Fix it on the client side with an explicit SqlDbType.VarChar parameter or IsUnicode(false) in the EF mapping, then sweep the plan cache for PlanAffectingConvert to find every other query with the same problem.

🧠 Quiz yourself on this lesson →

Ask the AI Tutor

Grounded in the course lessons β€” it cites its sources and says when it doesn't know.