RDS High CPU and Slow Queries
-
Phase 0: Before you start
-
Phase 1: The foundation
-
Phase 2: Operations and security
-
Phase 3: SQL Server on AWS
-
Phase 4: Troubleshooting step by step
-
Phase 5: Final project and career
When RDS SQL Server CPU sits at 95%, the first instinct is a bigger instance. Usually the real cause is one query reading the whole table thousands of times. Fixing RDS SQL Server high CPU properly means finding that query with Database Insights, proving the cost with Query Store, and changing the query or index, then measuring again.
You’ll put a realistic lookup workload on saat-sql’s 100,003 orders, find it, and fix it with an index. Then you’ll meet the two classic reasons an index exists but isn’t used: a function on the column and a data type mismatch. This is also the last lesson that uses saat-sql, so you’ll retire the SQL Server lab at the end.
| Time | You need | By the end you can |
|---|---|---|
| 90 minutes | Lessons 42 to 52 done (saat-sql with 100,003 orders and Database Insights on, Start-SqlLab.ps1), Windows PowerShell 5.1, signed in as saat-admin. Cost: a few cents for saat-sql running about an hour and a half. At the end you delete saat-sql, onprem-sql and the SQL lab’s supporting pieces. | Trace high CPU from CloudWatch to Database Insights to Query Store, read avg CPU and logical reads per execution, compare plans before and after an index, and fix non-sargable predicates and implicit conversions. |
What it is, in one line
RDS SQL Server high CPU is almost always a few expensive queries, often scanning tables because an index is missing or can’t be used, rather than an instance that’s too small.
In Simple Words
Finding one name in a phone book
To find “Ravi” in a phone book, you jump straight to R: an index seek. Now imagine the book isn’t sorted: you’d read every page, every time someone asks. That’s a scan, and doing it 1,000 times an hour wears out any reader.
Sometimes the book is sorted, but the question is “whose name, written in capitals, is RAVI?” You can’t jump to R for that; you read everything again.
Or the book is in English and the question arrives in a different script. You translate every entry before comparing. That’s an implicit conversion, and it’s just as slow.
Engineer's View
How to read CPU on RDS SQL Server
CPUUtilization says when; Database Insights’ DB load by SQL says which statements; Query Store (on by default for new databases in SQL Server 2022) says how much each costs per execution: avg_cpu_time, avg_logical_io_reads and executions, per plan. sys.dm_exec_query_stats gives the same since the last restart.
A query that reads hundreds of pages to return one row is scanning. Common causes: no suitable index; a non-sargable predicate such as UPPER(col), LEFT(col, n) or col + 0, which stops SQL Server seeking; and implicit conversion, for example an nvarchar parameter against a varchar column under a SQL collation, shown as CONVERT_IMPLICIT in the plan.
Rule out other causes too: burstable classes and CPUCreditBalance, storage limits (EBSIOBalance%, DiskQueueDepth, Lesson 43), and blocking (Lesson 51). Scale up only once the expensive queries are fixed.
How it works, step by step
Here is the path from symptom to fix.
1When and how much
CloudWatch CPU and DB load give the time window and size of the problem.
2Which query
Database Insights’ top SQL for that window, then Query Store for CPU and reads per execution and the plan used.
3Why, then fix and measure
Read the plan: scan or seek, any conversions. Change the index or query, run the same workload, and compare the numbers.
4Know why indexes get ignored
“We have an index” doesn’t mean it’s used:
Before-and-after numbers from Query Store make the change request easy to approve and easy to roll back.
✅ Checkpoint: You can trace high CPU to a query, explain avg CPU and reads per execution, and name two reasons an existing index isn’t used.
Hands-on lab: find, fix and prove a CPU-heavy query
Sign in, start the SQL lab and add the helpers. Start-Load runs two clients for a few minutes; Get-QueryStats reads Query Store per plan:
cd C:\aws-lab
aws login --profile saat-admin
$env:AWS_PROFILE = 'saat-admin'
.\Start-SqlLab.ps1
function Get-SqlPw([string]$Db) {
$a = aws rds describe-db-instances --db-instance-identifier $Db --query "DBInstances[0].MasterUserSecret.SecretArn" --output text
(aws secretsmanager get-secret-value --secret-id $a --query SecretString --output text | ConvertFrom-Json).password
}
$csb = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$csb['Data Source'] = '127.0.0.1,11433'; $csb['Initial Catalog'] = 'SaatShop'; $csb['User ID'] = 'saatadmin'; $csb['Password'] = (Get-SqlPw saat-sql); $csb['Encrypt'] = $true; $csb['TrustServerCertificate'] = $true
$load = {
param($cs, $sql, $type, $prefix, $seconds)
$end = (Get-Date).AddSeconds($seconds)
$cn = New-Object System.Data.SqlClient.SqlConnection $cs; $cn.Open()
$cm = $cn.CreateCommand(); $cm.CommandText = $sql
$p = $cm.Parameters.Add('@v', [System.Data.SqlDbType]$type, 100)
while ((Get-Date) -lt $end) { $p.Value = $prefix + (Get-Random -Minimum 1 -Maximum 100000); [void]$cm.ExecuteScalar() }
$cn.Close()
}
function Start-Load([string]$Sql, [string]$Type = 'NVarChar', [string]$Prefix = 'Customer ', [int]$Seconds = 300) {
1..2 | ForEach-Object { Start-Job -ScriptBlock $load -ArgumentList $csb.ConnectionString, $Sql, $Type, $Prefix, $Seconds } | Out-Null
Get-Job | Wait-Job | Receive-Job; Get-Job | Remove-Job
}
function Get-QueryStats {
sqlcmd -S "127.0.0.1,11433" -U saatadmin -C -d SaatShop -W -Q "SET NOCOUNT ON; SELECT RIGHT(qt.query_sql_text, 34) AS query_end, p.plan_id, CASE WHEN p.query_plan LIKE '%Index Seek%' THEN 'seek' ELSE 'scan' END AS op, CASE WHEN p.query_plan LIKE '%CONVERT_IMPLICIT%' THEN 'yes' ELSE 'no' END AS conversion, SUM(rs.count_executions) AS execs, CAST(SUM(rs.avg_cpu_time * rs.count_executions) / NULLIF(SUM(rs.count_executions), 0) / 1000.0 AS decimal(10,2)) AS avg_cpu_ms, CAST(SUM(rs.avg_logical_io_reads * rs.count_executions) / NULLIF(SUM(rs.count_executions), 0) AS int) AS avg_reads FROM sys.query_store_query_text qt JOIN sys.query_store_query q ON q.query_text_id = qt.query_text_id JOIN sys.query_store_plan p ON p.query_id = q.query_id JOIN sys.query_store_runtime_stats rs ON rs.plan_id = p.plan_id WHERE qt.query_sql_text LIKE '%dbo.Orders WHERE%' AND qt.query_sql_text NOT LIKE '%query_store%' GROUP BY qt.query_sql_text, p.plan_id, p.query_plan ORDER BY p.plan_id"
}
function Get-CpuMax([int]$Minutes = 6) {
$e = (Get-Date).ToUniversalTime(); $s = $e.AddMinutes(-$Minutes)
aws cloudwatch get-metric-statistics --namespace AWS/RDS --metric-name CPUUtilization --dimensions Name=DBInstanceIdentifier,Value=saat-sql --start-time $s.ToString('o') --end-time $e.ToString('o') --period 60 --statistics Maximum --query "max(Datapoints[].Maximum)" --output text
}
sqlcmd -S "127.0.0.1,11433" -U saatadmin -C -d SaatShop -Q "ALTER DATABASE SaatShop SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE); ALTER DATABASE SaatShop SET QUERY_STORE CLEAR; SELECT COUNT(*) AS orders FROM dbo.Orders;"You should see something like this:
orders
-----------
1000031Run a lookup workload and watch CPU
Paste this whole block. Two clients look up random customers for 5 minutes:
Start-Load 'SELECT COUNT(*), SUM(Amount) FROM dbo.Orders WHERE Customer = @v'
"CPU max in the last 6 minutes: $(Get-CpuMax)%"You should see something like this:
CPU max in the last 6 minutes: 94.2%saat-sql’s CPU went close to 100% for a lookup that returns one row each time. Open CloudWatch > Database Insights for saat-sql: DB load sits near the vCPU line, almost all of it CPU, and one statement dominates the top SQL list.

2Prove the cost with Query Store
Paste this:
Get-QueryStatsYou should see something like this:
query_end plan_id op conversion execs avg_cpu_ms avg_reads
---------------------------------- ------- ---- ---------- ----- ---------- ---------
dbo.Orders WHERE Customer = @v 1 scan no 2412 38.71 812About 40 ms of CPU and 812 page reads per execution to find one customer: the whole table, every time. That’s what “high CPU” usually looks like up close.

3Fix it with an index and measure again
Paste this whole block:
sqlcmd -S "127.0.0.1,11433" -U saatadmin -C -d SaatShop -Q "CREATE INDEX IX_Orders_Customer ON dbo.Orders (Customer) INCLUDE (Amount);"
Start-Load 'SELECT COUNT(*), SUM(Amount) FROM dbo.Orders WHERE Customer = @v' -Seconds 180
"CPU max in the last 4 minutes: $(Get-CpuMax 4)%"
Get-QueryStatsYou should see something like this:
CPU max in the last 4 minutes: 23.5%
query_end plan_id op conversion execs avg_cpu_ms avg_reads
---------------------------------- ------- ---- ---------- ----- ---------- ---------
dbo.Orders WHERE Customer = @v 1 scan no 2412 38.71 812
dbo.Orders WHERE Customer = @v 2 seek no 61530 0.05 3The same query got a new plan: a seek with 3 reads and a twentieth of a millisecond of CPU, so the clients ran 25 times as many lookups at a quarter of the CPU. No instance change, no downtime.

✅ Checkpoint: CPU was over 90% under the lookup workload, Query Store showed a scan with about 800 reads per run, and after the index the new plan seeks with about 3 reads.
Break It, Then Fix It
Two releases that bring the CPU straight back, with the index still in place.
1Break it: a function on the column
A developer makes the search “case-insensitive” by adding UPPER():
Start-Load 'SELECT COUNT(*), SUM(Amount) FROM dbo.Orders WHERE UPPER(Customer) = UPPER(@v)' -Seconds 180
"CPU max: $(Get-CpuMax 4)%"
Get-QueryStats | Select-String 'UPPER|plan_id'You’ll get something like this:
CPU max: 92.8%
query_end plan_id op conversion execs avg_cpu_ms avg_reads
er) = UPPER(@v) 3 scan no 2280 41.20 431UPPER() has to be applied to every row before comparing, so SQL Server can’t seek; it scans the index instead.
And it was never needed: RDS uses a case-insensitive collation (CI), so Customer = @v already ignores case. Fix: compare the column as it is (the query from step 3). If you truly need a transformed value, index a computed column instead.
2Break it: a type mismatch
A new feature adds a varchar reference column with its own index, but the app sends the parameter as nvarchar, as many ORMs do by default:
sqlcmd -S "127.0.0.1,11433" -U saatadmin -C -d SaatShop -Q "ALTER TABLE dbo.Orders ADD Ref varchar(20) NULL;"
sqlcmd -S "127.0.0.1,11433" -U saatadmin -C -d SaatShop -Q "UPDATE dbo.Orders SET Ref = 'R' + CAST(OrderId AS varchar(10)); CREATE INDEX IX_Orders_Ref ON dbo.Orders (Ref) INCLUDE (Amount);"
Start-Load 'SELECT COUNT(*), SUM(Amount) FROM dbo.Orders WHERE Ref = @v' -Type NVarChar -Prefix 'R' -Seconds 180
"CPU max: $(Get-CpuMax 4)%"
Get-QueryStats | Select-String 'Ref|plan_id'You’ll get something like this:
CPU max: 88.1%
query_end plan_id op conversion execs avg_cpu_ms avg_reads
dbo.Orders WHERE Ref = @v 4 scan yes 2655 33.90 405
To compare varchar with nvarchar, SQL Server converts every Ref value (CONVERT_IMPLICIT), and under RDS’s SQL collation that rules out a seek. This one hides in ORMs and drivers, and the query text looks perfect. Fix: send the parameter as varchar(20), matching the column:
Start-Load 'SELECT COUNT(*), SUM(Amount) FROM dbo.Orders WHERE Ref = @v' -Type VarChar -Prefix 'R' -Seconds 120
Get-QueryStats | Select-String 'Ref|plan_id'You should see something like this:
query_end plan_id op conversion execs avg_cpu_ms avg_reads
dbo.Orders WHERE Ref = @v 4 scan yes 2655 33.90 405
dbo.Orders WHERE Ref = @v 5 seek no 44120 0.06 3Now retire the SQL Server lab. This is the last lesson that uses saat-sql and onprem-sql, so they and their supporting pieces are deleted (keep a final snapshot instead of --skip-final-snapshot if you want to come back later):
$acct = aws sts get-caller-identity --query Account --output text
.\Stop-SqlLab.ps1 -KeepDbRunning
aws rds delete-db-instance --db-instance-identifier saat-sql --skip-final-snapshot --delete-automated-backups --query "DBInstance.DBInstanceStatus" --output text
$src = aws ec2 describe-instances --filters "Name=tag:Name,Values=onprem-sql" "Name=instance-state-name,Values=stopped,running" --query "Reservations[0].Instances[0].InstanceId" --output text
if ($src -and $src -ne 'None') { aws ec2 terminate-instances --instance-ids $src | Out-Null; aws ec2 wait instance-terminated --instance-ids $src }
$srcSg = aws ec2 describe-security-groups --filters "Name=group-name,Values=saat-onprem-sql-sg" --query "SecurityGroups[0].GroupId" --output text
if ($srcSg -and $srcSg -ne 'None') { aws ec2 delete-security-group --group-id $srcSg }
aws rds wait db-instance-deleted --db-instance-identifier saat-sql
aws rds delete-option-group --option-group-name saat-sqlserver-ex-16-og
aws rds delete-db-parameter-group --db-parameter-group-name saat-sqlserver-ex-16
aws iam delete-role-policy --role-name saat-rds-backup-role --policy-name saat-sqlbak-s3
aws iam delete-role --role-name saat-rds-backup-role
aws s3 rb "s3://saat-sqlbak-$acct-ap-south-1-an" --force | Out-Null
aws rds describe-db-instances --query "DBInstances[].DBInstanceIdentifier" --output textThe last command should print nothing.
✅ Checkpoint: You fixed a non-sargable UPPER() by comparing the column directly and an implicit conversion by matching the parameter type. saat-sql, onprem-sql, their security group, groups, backup role and bucket are gone.
This happened at work
The bigger instance that bought one week
A ticketing company’s RDS SQL Server hit 100% CPU every evening during sales. They doubled the instance class; CPU dropped to 60% and climbed back to 100% a week later as traffic grew. They doubled it again.
A consultant opened Query Store and sorted by total CPU. One query, checking seat availability, used 70% of it: the app sent the event code as nvarchar to a varchar column, so every check scanned a 40-million-row table.
One line in the data access layer fixed the type. CPU at peak fell to 15% and they went back two instance sizes, saving more each month than the consultant cost. Their code review checklist now includes parameter types for every new query.
Common mistakes and troubleshooting
| Mistake | What happens | What to do |
|---|---|---|
| Scaling up first | Costs rise, scans continue | Find and fix the top queries |
| Functions on indexed columns | Index scans, high CPU | Rewrite predicates or use computed columns |
| Parameter types that don’t match columns | CONVERT_IMPLICIT, scans | Match types in the app or ORM |
| Measuring with one run | Noise hides the change | Same workload before and after, per-execution numbers |
| Query Store off or read-only | No history when you need it | Keep it on, sized, READ_WRITE |
| Ignoring CPU credits on db.t classes | Throttled or surprise costs | Watch CPUCreditBalance; use m or r classes for steady load |
Interview questions
Click a question to open it. Each one is written the way an interviewer would ask it. Under it you will see why they ask it and one way you could answer, in your own words.
Fresher level
1. Users say the app is slow, and you see the RDS SQL Server instance sitting at 90 percent CPU. Where would you start looking?
Why they ask this: They want to see that you find the cause before you change anything. They also want to know you can use the AWS and SQL Server tools together.
How you might answer: First I’d check the CPUUtilization graph in CloudWatch to see when it started, and whether that lines up with a release or a batch job. Then I’d open Database Insights for the instance and look at the top SQL, because one statement usually explains most of the load.
For that statement I’d check Query Store, or sys.dm_exec_query_stats, to see CPU and reads per run. That tells me if the query itself is the problem.
2. You see a query that returns one row, but it does about 800 logical reads every time. What does that tell you?
Why they ask this: They are checking that you can read the numbers and connect them to how SQL Server finds rows.
How you might answer: It tells me SQL Server is scanning the table or index instead of seeking straight to the row. A one-row lookup should need only a handful of reads.
Next I’d look at the plan, the indexes on that column, and the data type of the parameter.
3. Can you explain in simple words what Query Store is and why you’d turn it on?
Why they ask this: They want to know if you have used it for real, not just heard the name.
How you might answer: Query Store is a feature inside each database that saves the query text, the plans it used, and how much CPU and reads each plan took over time. I turn it on so that when a query suddenly gets slower, I can see which plan it used before and after the change.
If I need a quick fix, I can force the old plan from there.
2–5 years experience
4. There is an index on the column, but the query still does a scan. What are the usual reasons?
Why they ask this: This checks real troubleshooting experience. People who have only read about indexes often think an index always gets used.
How you might answer: The most common one is a function or a calculation on the column, like UPPER(Customer), which stops SQL Server from seeking. Another is a data type mismatch, for example an nvarchar parameter against a varchar column, which shows up as CONVERT_IMPLICIT in the plan.
It can also be a leading wildcard in a LIKE, a filter that matches too many rows, or old statistics. So I’d open the plan and check the predicate first.
5. Your manager says, let’s just move to a bigger instance class. When would you agree, and when would you push back?
Why they ask this: They want to see judgement and a sense of cost, not just technical knowledge.
How you might answer: I’d push back if the top queries are scanning or doing far more reads than they need, because a bigger instance just makes a bad query cost more. I’d agree once those queries are already efficient and DB load still stays above the vCPU line because there really is more work.
Even then, I’d ask whether a read replica or caching would help first.
6. A query was fine yesterday. After a statistics update it’s slow today. What would you do, step by step?
Why they ask this: This checks how you handle a plan regression under pressure: a quick, safe fix first, then the real fix.
How you might answer: I’d find the query in Query Store under regressed queries and compare the old plan with the new one. To get users working again, I’d force the old plan.
Then I’d look for the real cause, such as a missing index, parameter sniffing or a skewed column. Once that’s fixed, I’d remove the forced plan.
Senior level
7. Say you’re responsible for 50 SQL Server databases. How would you set up regular performance reviews so problems are caught early?
Why they ask this: For a senior role they want to see a repeatable process and good habits across a team, not one-off firefighting.
How you might answer: I’d turn on Query Store everywhere and send a weekly report of the top CPU queries and any regressed queries. I’d use Database Insights dashboards for load, and review index usage every few months.
In code review I’d check parameter data types and avoid functions on indexed columns. I’d only talk about bigger instances after the query fixes are done.
8. You need to add an index to a busy production table. How would you do it without hurting users?
Why they ask this: They want to see that you think about risk, blocking and timing, not only about the index itself.
How you might answer: First I’d test the index on a copy of the database and measure the plan and the benefit. On Enterprise edition I’d build it with ONLINE = ON; on Standard or Express I’d plan it for a quiet time, because the build locks the table.
While it runs, I’d watch for blocking and log growth, and afterwards I’d compare CPU and reads with the numbers from before.
Quick quiz
Q1. What did the index change for the lookup?
- Nothing
- Scan to seek, 812 to 3 reads
- More CPU
- A new table
Show answer
B — A seek reads a few pages.
Q2. Why did UPPER(Customer) scan?
- No index
- Functions on the column stop seeks
- Wrong password
- Disk full
Show answer
B — The predicate is non-sargable.
Q3. What shows an implicit conversion in a plan?
- INDEX SEEK
- CONVERT_IMPLICIT
- PARALLELISM
- SORT
Show answer
B — The column is converted per row.
Q4. Fix for nvarchar parameter vs varchar column?
- Bigger instance
- Send varchar matching the column
- Drop the index
- Turn off Query Store
Show answer
B — Match the types.
Q5. Which tool gave CPU and reads per execution per plan?
- CloudTrail
- Query Store
- S3
- Route 53
Show answer
B — sys.query_store_* views.
Quick recap
- Follow CPU from CloudWatch to Database Insights to Query Store to the plan.
- Per-execution CPU and reads reveal scans.
- An index fixes a missing seek; functions on columns and type mismatches defeat it.
- Measure the same workload before and after every change.
- Fix queries before scaling the instance.
Next lesson
Lesson 66: Load balancer 502, 503 and 504 errors → You’ll find out why an Application Load Balancer returns 502, 503 and 504, and fix unhealthy targets.
Further reading: RDS SQL Server high CPU
Want to go deeper into RDS SQL Server high CPU? Read Monitor performance by using the Query Store and Best practices for Amazon RDS. You can also see the full plan for this course on the AWS Cloud Administration course page.
