A poison message is one that fails processing every time it is received - a malformed payload, a bug triggered only by that data, a downstream dependency that is permanently down for that record. Without a safety net, SQS redelivers it forever, burning consumer capacity and cluttering logs.
A dead-letter queue (DLQ) is where those messages go instead. This page covers configuring a redrive policy that moves a message to a DLQ after repeated failed receives, and moving messages back once you have fixed the underlying issue.
Create a main queue and a DLQ, attach a redrive policy, simulate a message failing repeatedly, confirm it lands in the DLQ, then redrive it back.
# --- Python (boto3) ---import boto3, jsonsqs = boto3.client("sqs", region_name="us-east-1")# 1. Create the DLQ first - it is a normal queue with no special attribute.dlq_url = sqs.create_queue(QueueName="jobs-dlq")["QueueUrl"]dlq_arn = sqs.get_queue_attributes( QueueUrl=dlq_url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"]# 2. Create the main queue with a redrive policy pointing at the DLQ.main_url = sqs.create_queue( QueueName="jobs", Attributes={ "RedrivePolicy": json.dumps({"deadLetterTargetArn": dlq_arn, "maxReceiveCount": "3"}), "VisibilityTimeout": "5", },)["QueueUrl"]# 3. Send a message and receive it repeatedly without deleting it -# simulating a consumer that keeps failing on this payload.sqs.send_message(QueueUrl=main_url, MessageBody="bad-payload")for _ in range(3): sqs.receive_message(QueueUrl=main_url, WaitTimeSeconds=1) # No DeleteMessage call - the message becomes visible again after the # visibility timeout, counting one more receive against maxReceiveCount.# 4. After the 3rd failed receive, the message has moved to the DLQ.dlq_msgs = sqs.receive_message(QueueUrl=dlq_url, WaitTimeSeconds=5).get("Messages", [])print("in DLQ:", [m["Body"] for m in dlq_msgs])# 5. Once the bug is fixed, redrive messages from the DLQ back to the source queue.sqs.start_message_move_task(SourceArn=dlq_arn)
// --- TypeScript (AWS SDK v3) ---import { SQSClient, CreateQueueCommand, GetQueueAttributesCommand, SendMessageCommand, ReceiveMessageCommand, StartMessageMoveTaskCommand,} from "@aws-sdk/client-sqs";const sqs = new SQSClient({ region: "us-east-1" });// 1. Create the DLQ first - it is a normal queue with no special attribute.const { QueueUrl: dlqUrl } = await sqs.send(new CreateQueueCommand({ QueueName: "jobs-dlq" }));const dlqAttrs = await sqs.send(new GetQueueAttributesCommand({ QueueUrl: dlqUrl, AttributeNames: ["QueueArn"] }));const dlqArn = dlqAttrs.Attributes!.QueueArn;// 2. Create the main queue with a redrive policy pointing at the DLQ.const { QueueUrl: mainUrl } = await sqs.send(new CreateQueueCommand({ QueueName: "jobs", Attributes: { RedrivePolicy: JSON.stringify({ deadLetterTargetArn: dlqArn, maxReceiveCount: "3" }), VisibilityTimeout: "5", },}));// 3. Send a message and receive it repeatedly without deleting it -// simulating a consumer that keeps failing on this payload.await sqs.send(new SendMessageCommand({ QueueUrl: mainUrl, MessageBody: "bad-payload" }));for (let i = 0; i < 3; i++) { await sqs.send(new ReceiveMessageCommand({ QueueUrl: mainUrl, WaitTimeSeconds: 1 })); // No DeleteMessageCommand call - the message becomes visible again after the // visibility timeout, counting one more receive against maxReceiveCount.}// 4. After the 3rd failed receive, the message has moved to the DLQ.const dlqResp = await sqs.send(new ReceiveMessageCommand({ QueueUrl: dlqUrl, WaitTimeSeconds: 5 }));console.log("in DLQ:", (dlqResp.Messages ?? []).map((m) => m.Body));// 5. Once the bug is fixed, redrive messages from the DLQ back to the source queue.await sqs.send(new StartMessageMoveTaskCommand({ SourceArn: dlqArn }));
What this demonstrates:
The DLQ is created exactly like any other queue - there is no IsDeadLetterQueue flag.
The RedrivePolicy on the source queue is what links it to a DLQ and sets the failure threshold.
Repeated receive-without-delete cycles are what count toward maxReceiveCount, not repeated SendMessage calls.
StartMessageMoveTask moves messages back to their original source queue asynchronously, in bulk.
A "failed receive" is any ReceiveMessage that is not followed by a DeleteMessage for that message before its visibility timeout expires. SQS tracks an approximate receive count per message internally. When that count exceeds maxReceiveCount, SQS routes the message to the DLQ named in deadLetterTargetArn instead of making it visible again on the source queue.
This means a consumer that crashes mid-processing, times out, or explicitly fails and does not delete the message all look identical to SQS: one more failed receive.
RedrivePolicy is a JSON-encoded queue attribute set via SetQueueAttributes (or at CreateQueue time), with two fields:
deadLetterTargetArn - the ARN of an existing SQS queue to route failed messages to.
maxReceiveCount - how many receives are allowed before a message is moved.
The DLQ itself needs no special attribute. What optionally restricts it is RedriveAllowPolicy on the DLQ, which can limit which source queues are allowed to target it - useful when a shared DLQ should only accept redirects from an approved list of queues, or when you want to allow any queue in the account.
A standard queue's DLQ can be any standard queue. A FIFO queue's DLQ must itself be a FIFO queue, keeping the ordering guarantee intact for whatever ends up in the DLQ.
Once you understand and fix why messages failed, StartMessageMoveTask moves them from the DLQ back to the original source queue (or a different destination) in bulk, at a configurable rate, without you having to manually receive and re-send each one. You can check progress with ListMessageMoveTasks and cancel with CancelMessageMoveTask if needed.
No DLQ configured at all - poison messages retry indefinitely, consuming worker capacity and repeating the same failure in logs. Fix: attach a RedrivePolicy to every production queue.
maxReceiveCount set to 1 - a single transient failure (a brief downstream outage) permanently exiles a message that would have succeeded on retry. Fix: allow a handful of retries (commonly 3-5) before giving up.
Forgetting the DLQ needs its own visibility timeout and retention - a DLQ with a short MessageRetentionPeriod can quietly drop poison messages before anyone looks at them. Fix: set a generous retention period (up to 14 days) on the DLQ.
No monitoring on the DLQ - messages pile up silently with nobody aware. Fix: alarm on ApproximateNumberOfMessagesVisible for the DLQ via CloudWatch.
Mismatched FIFO/standard pairing - a FIFO queue cannot target a standard queue as its DLQ. Fix: match queue types, .fifo DLQ for .fifo source queues.
Manually re-sending DLQ messages one by one - this is slow and loses the original message attributes if done carelessly. Fix: use StartMessageMoveTask to redrive in bulk.
What exactly triggers a message moving to the DLQ?
Exceeding maxReceiveCount receives without a following DeleteMessage. Each receive-without-delete cycle (visibility timeout expiring and the message becoming visible again) counts once toward the limit.
Does the DLQ need a special attribute to be a DLQ?
No. Any SQS queue can serve as a DLQ - it is a normal queue. What makes it a DLQ is another queue's RedrivePolicy pointing to its ARN.
Can I restrict which queues use my queue as a DLQ?
Yes, with RedriveAllowPolicy on the DLQ, which can allow all source queues, deny all, or allow only a specific list of queue ARNs.
How do I get messages out of the DLQ once I fix the bug?
Call StartMessageMoveTask with the DLQ's ARN as the source. It redrives messages back to their original source queue in bulk asynchronously; track progress with ListMessageMoveTasks.
Can a FIFO queue's DLQ be a standard queue?
No. A FIFO queue requires its DLQ to also be a FIFO queue, so ordering is preserved for whatever messages end up dead-lettered.
What is a reasonable maxReceiveCount?
Commonly 3 to 5. Too low and transient failures exile recoverable messages; too high and genuinely broken messages retry many times before you notice, wasting consumer capacity.