Photo by Leon Overweel on Unsplash
To find SQS queues without a dead-letter queue, list every queue with ListQueues, read each one’s RedrivePolicy with GetQueueAttributes, and flag the queues that have none. Skip queues that are themselves DLQs, check that each deadLetterTargetArn still exists, and read the DLQ’s ApproximateNumberOfMessages to see what’s already failing.
A queue without a dead-letter queue has no place to put a message that keeps failing. On a standard queue, that message is received, fails and comes back until its retention period runs out, then disappears without a trace. On a queue with a DLQ, it moves aside after a set number of receives, where you can inspect it and redrive it. This example is for engineers who want to find SQS queues without a dead letter queue across a Region, and catch the half-configured ones too.
You’ll get a report-only TypeScript script for the AWS SDK for JavaScript v3, part of our AWS SDK v3 audit and cleanup examples. If you’re building the producer and consumer side, start with the guide to send and receive SQS messages with AWS SDK v3 in TypeScript; this page audits the queues those consumers read from. If an SNS topic fans out to them, the guide to publish an SNS message with AWS SDK v3 in TypeScript covers the publishing side.
How does a redrive policy move messages to a DLQ?
The pattern is older than SQS. Enterprise Integration Patterns calls it a Dead Letter Channel: when a messaging system can’t or shouldn’t deliver a message, it moves it to a separate channel instead of losing it. In SQS, the source queue’s RedrivePolicy attribute is a JSON string with two fields:
deadLetterTargetArn: the ARN of the queue that receives failed messages.maxReceiveCount: how many times a message can be received from the source queue. When a message’s receive count exceeds it, SQS moves the message to the DLQ.
The rules that the audit checks come from the SQS documentation:
| Rule | Why it matters |
|---|---|
| The DLQ must be in the same account and Region as the source queue | A central DLQ for several accounts or Regions isn’t an option |
| A FIFO queue’s DLQ must be FIFO; a standard queue’s DLQ must be standard | A DLQ can only serve queues of its own type |
A low maxReceiveCount, such as 1, moves a message after one failed receive |
A single timeout or deploy sends good messages to the DLQ |
| On standard queues, expiry uses the original enqueue time, even after the move | A DLQ with the same retention as its source deletes messages early |
The last rule is easy to miss. If a message spends 1 day in the source queue and the DLQ keeps messages for 4 days, the message is deleted from the DLQ after 3 days. AWS’s advice is to set the DLQ’s retention longer than the source queue’s. FIFO queues reset the enqueue timestamp when a message moves, so the check applies to standard queues only. The documentation also warns against a DLQ on a FIFO queue when you can’t afford to break the exact order of messages.
What does the script do?
- Lists every queue
paginateListQueueswith a page size of 1,000. WithoutMaxResults,ListQueuesreturns at most 1,000 URLs and noNextToken, so larger accounts would be cut off. - Reads five attributes per queue
QueueArn,FifoQueue,ApproximateNumberOfMessages,MessageRetentionPeriodandRedrivePolicy. - Separates sources from DLQsA queue that another queue’s policy points to is a DLQ. It isn’t flagged for lacking its own DLQ; instead the script reports its depth and compares retention with its sources.
- Checks each redrive policyFlags targets that no longer exist and a
maxReceiveCountof 1. - Reports onlyPrints a table, sorted with findings first.
--failexits with code 1 when a source queue has no DLQ, for use in CI.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-sqspackage. - A profile with a default Region, or
AWS_REGION. Queues are Regional; run once per Region.
Which IAM permissions does it need?
Two read-only actions. GetQueueAttributes is scoped to the queues in one account and Region; replace 123456789012 and us-east-1.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListQueues",
"Effect": "Allow",
"Action": "sqs:ListQueues",
"Resource": "*"
},
{
"Sid": "ReadQueueAttributes",
"Effect": "Allow",
"Action": "sqs:GetQueueAttributes",
"Resource": "arn:aws:sqs:us-east-1:123456789012:*"
}
]
}
To draft a policy like this from any script, paste it into the free IAM policy generator for TypeScript code.
The full script to find SQS queues without a dead-letter queue
// find-sqs-queues-without-dlq.ts
// Lists every SQS queue in one Region and reports which ones have no dead-letter queue,
// which redrive policies point at a queue that no longer exists, how deep each DLQ is,
// and DLQs whose retention is shorter than their source queue's. Report only.
// Usage: npx tsx find-sqs-queues-without-dlq.ts [--prefix orders] [--fail]
import { SQSClient, GetQueueAttributesCommand, paginateListQueues } from "@aws-sdk/client-sqs";
const args = process.argv.slice(2);
const prefixIdx = args.indexOf("--prefix");
const prefix = prefixIdx >= 0 ? args[prefixIdx + 1] : undefined;
const failOnFindings = args.includes("--fail"); // exit 1 for CI when a source queue has no DLQ
const sqs = new SQSClient({}); // region from AWS_REGION or your profile
interface Queue {
name: string;
arn: string;
fifo: boolean;
depth: number;
retentionSec: number;
dlqArn?: string;
maxReceiveCount?: number;
}
async function loadQueues(): Promise<Queue[]> {
const queues: Queue[] = [];
// MaxResults (pageSize) must be set, or ListQueues returns at most 1,000 URLs and no NextToken.
for await (const page of paginateListQueues({ client: sqs, pageSize: 1000 }, { QueueNamePrefix: prefix })) {
for (const url of page.QueueUrls ?? []) {
const { Attributes: a = {} } = await sqs.send(new GetQueueAttributesCommand({
QueueUrl: url,
AttributeNames: ["QueueArn", "FifoQueue", "ApproximateNumberOfMessages", "MessageRetentionPeriod", "RedrivePolicy"],
}));
const redrive = a.RedrivePolicy ? (JSON.parse(a.RedrivePolicy) as { deadLetterTargetArn?: string; maxReceiveCount?: number | string }) : undefined;
queues.push({
name: url.split("/").pop() ?? url,
arn: a.QueueArn ?? "",
fifo: a.FifoQueue === "true",
depth: Number(a.ApproximateNumberOfMessages ?? 0),
retentionSec: Number(a.MessageRetentionPeriod ?? 0),
dlqArn: redrive?.deadLetterTargetArn,
maxReceiveCount: redrive?.maxReceiveCount === undefined ? undefined : Number(redrive.maxReceiveCount),
});
}
}
return queues;
}
async function main(): Promise<void> {
const queues = await loadQueues();
const byArn = new Map(queues.map((q) => [q.arn, q]));
const dlqArns = new Set(queues.flatMap((q) => (q.dlqArn ? [q.dlqArn] : [])));
const days = (sec: number) => Math.round(sec / 86_400 * 10) / 10;
const rows = queues.map((q) => {
const row = { Queue: q.name, Type: q.fifo ? "FIFO" : "standard", Role: "source", DLQ: "-", MaxReceive: "-", Depth: q.depth, Finding: "ok" };
if (dlqArns.has(q.arn)) {
const sources = queues.filter((s) => s.dlqArn === q.arn);
const shortest = sources.find((s) => !q.fifo && q.retentionSec <= s.retentionSec);
row.Role = `DLQ for ${sources.length}`;
if (q.depth > 0) row.Finding = `${q.depth} failed messages waiting`;
else if (shortest) row.Finding = `retention ${days(q.retentionSec)}d not longer than ${shortest.name} (${days(shortest.retentionSec)}d)`;
return row;
}
if (!q.dlqArn) {
row.Finding = "NO DLQ";
return row;
}
const target = byArn.get(q.dlqArn);
row.DLQ = q.dlqArn.split(":").pop() ?? q.dlqArn;
row.MaxReceive = String(q.maxReceiveCount ?? "-");
if (!target && !prefix) row.Finding = "DLQ target does not exist";
else if (q.maxReceiveCount === 1) row.Finding = "maxReceiveCount 1: one failed receive moves the message";
return row;
});
rows.sort((a, b) => Number(b.Finding !== "ok") - Number(a.Finding !== "ok") || a.Queue.localeCompare(b.Queue));
console.table(rows);
const missing = rows.filter((r) => r.Finding === "NO DLQ").length;
const usedDlqs = [...dlqArns].filter((arn) => byArn.has(arn)).length;
console.log(`${queues.length} queues, ${usedDlqs} used as DLQs, ${missing} source queues without a DLQ.`);
console.log("Report only: no queue attributes were changed.");
if (failOnFindings && missing > 0) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-sqs
npm install --save-dev tsx typescript
# Every queue in the Region
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-sqs-queues-without-dlq.ts
# In CI: fail the job when any source queue has no DLQ
AWS_REGION=us-east-1 npx tsx find-sqs-queues-without-dlq.ts --fail
Each GetQueueAttributes call is one SQS request. The first 1 million requests a month are free, then standard-queue requests cost $0.40 per million in us-east-1 as of September 2026 (AWS Price List), so the audit costs next to nothing.
Sample output
┌─────────┬────────────────────┬────────────┬─────────────┬────────────────────┬────────────┬───────┬───────────────────────────────────────────────────────────┐
│ (index) │ Queue │ Type │ Role │ DLQ │ MaxReceive │ Depth │ Finding │
├─────────┼────────────────────┼────────────┼─────────────┼────────────────────┼────────────┼───────┼───────────────────────────────────────────────────────────┤
│ 0 │ 'billing-events' │ 'standard' │ 'source' │ '-' │ '-' │ 0 │ 'NO DLQ' │
│ 1 │ 'email-outbox' │ 'standard' │ 'source' │ 'email-outbox-dlq' │ '1' │ 3 │ 'maxReceiveCount 1: one failed receive moves the message' │
│ 2 │ 'orders-dlq' │ 'standard' │ 'DLQ for 1' │ '-' │ '-' │ 42 │ '42 failed messages waiting' │
│ 3 │ 'payments.fifo' │ 'FIFO' │ 'source' │ '-' │ '-' │ 0 │ 'NO DLQ' │
│ 4 │ 'search-index' │ 'standard' │ 'source' │ 'search-index-dlq' │ '5' │ 0 │ 'DLQ target does not exist' │
│ 5 │ 'thumbnails-dlq' │ 'standard' │ 'DLQ for 1' │ '-' │ '-' │ 0 │ 'retention 4d not longer than thumbnails (4d)' │
│ 6 │ 'email-outbox-dlq' │ 'standard' │ 'DLQ for 1' │ '-' │ '-' │ 0 │ 'ok' │
│ 7 │ 'orders' │ 'standard' │ 'source' │ 'orders-dlq' │ '5' │ 118 │ 'ok' │
│ 8 │ 'thumbnails' │ 'standard' │ 'source' │ 'thumbnails-dlq' │ '10' │ 7 │ 'ok' │
└─────────┴────────────────────┴────────────┴─────────────┴────────────────────┴────────────┴───────┴───────────────────────────────────────────────────────────┘
9 queues, 3 used as DLQs, 2 source queues without a DLQ.
Report only: no queue attributes were changed.
Queue names are illustrative. Read the findings top down:
billing-eventsandpayments.fifohave no DLQ. Create one of the same type and set the redrive policy in your infrastructure code.orders-dlqholds 42 failed messages. That’s a production incident in slow motion; find out why before retention deletes them.search-indexpoints at a DLQ that was deleted, so it has no working DLQ at all.thumbnails-dlqkeeps messages for 4 days, the same as its source, so failed messages expire early.
How should you watch a dead-letter queue?
A DLQ that nobody looks at only delays the loss. Put a CloudWatch alarm on the DLQ’s ApproximateNumberOfMessagesVisible metric with a threshold of 1, and route it to the team that owns the consumer. Alarms on quiet queues often sit in INSUFFICIENT_DATA, which is expected; the example to find CloudWatch alarms stuck in INSUFFICIENT_DATA explains when that’s fine and when the alarm is dead. When the DLQ does fill up, work through the consumer’s errors with the guide to troubleshoot AWS infrastructure with an AI CLI, then use DLQ redrive to move the messages back once the fix is deployed.
A DLQ also changes how you size retries in the consumer. If your consumer calls other AWS services, the SDK’s own retries happen inside one receive, so they don’t count toward maxReceiveCount. The guide to configure retry and timeout settings in AWS SDK for JavaScript v3 helps keep those retries inside the queue’s visibility timeout.
Troubleshooting
- The script reports fewer queues than the console.
ListQueuesis Regional. CheckAWS_REGION, and remove--prefix. - “DLQ target does not exist”. Compare the ARN in the redrive policy with your queues: a typo in a hand-written policy, or a DLQ deleted after the policy was set. The script skips this check when
--prefixis set, because the DLQ’s name may not match the prefix. - Depth doesn’t match the console.
ApproximateNumberOfMessagesis approximate and can take at least a minute to settle after producers stop. AccessDeniedonGetQueueAttributes. The policy’s Region or account doesn’t match the queue ARN. The steps to troubleshoot AWS IAM access denied errors walk through the rest.RequestThrottledon accounts with thousands of queues. The SDK retries it. Rerun with--prefixto audit one team’s queues at a time.
Ask ChatWithCloud instead
You can also ask ChatWithCloud “Which SQS queues in us-east-1 don’t have a dead-letter queue?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result, like the checks in the guide to analyze your AWS security posture with an AI CLI. It uses one profile and Region per session and runs generated code without asking first, so connect ChatWithCloud to a read-only AWS profile. For a check that runs in CI on every deploy, use the script with --fail.
Frequently asked questions
Does every SQS queue need a dead-letter queue?
Every queue whose consumer can fail on a message should have one. The exception is a FIFO queue where moving one message aside would break the order the rest depend on.
Can an SQS FIFO queue use a standard queue as its DLQ?
No. A FIFO queue’s dead-letter queue must also be FIFO, and a standard queue’s must be standard.
What maxReceiveCount should I use?
High enough to survive a transient failure, such as a timeout during a deploy. A value of 1 moves a message after a single failed receive, which usually sends healthy messages to the DLQ.
Can a dead-letter queue be in another AWS account?
No. The DLQ must be in the same account and Region as the source queue.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
