SQS FIFO advertises exactly-once delivery, and it delivers on that within a narrow, precisely defined scope: a five-minute window, on the producer side. Everything outside that window — a producer retrying after six minutes, a consumer whose visibility timeout expires — is still at-least-once and still needs a deduplication table. This guide sits under message queue deduplication patterns.
Prerequisites. An SQS FIFO queue, and the framing from exactly-once vs effectively-once semantics.
Step 1 — What the five-minute interval covers
The deduplication ID is a producer-side mechanism. It answers “did this producer already send this message?” and nothing else. Consumer-side redelivery — by far the more common duplicate source in practice — is unaffected by it.
Step 2 — Set an explicit deduplication ID
SQS offers content-based deduplication, which hashes the message body. Do not use it for anything where two legitimately identical messages can occur.
import boto3
sqs = boto3.client("sqs")
# GOOD: explicit id derived from the business event, stable across producer retries.
sqs.send_message(
QueueUrl=QUEUE_URL,
MessageBody=json.dumps(event),
MessageDeduplicationId=event["id"], # ULID from the producing service
MessageGroupId=event["account_id"], # ordering scope — see step 3
)
# RISKY: content-based deduplication collapses two genuine identical events.
# "Customer bought the same £3.50 coffee twice in one minute" becomes one message.
sqs.create_queue(
QueueName="payments.fifo",
Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"},
)
An explicit ID must be stable across the producer’s own retries and unique across distinct events. A ULID or UUIDv7 minted once per business intent satisfies both, and its 26 or 36 characters fit comfortably inside the 128-character limit.
Step 3 — Choose the message group ID carefully
This is where FIFO queues most often disappoint on throughput. Messages sharing a MessageGroupId are delivered in strict order and processed one at a time; a single group ID serialises the entire queue.
| Group ID choice | Ordering guarantee | Parallelism | When it is right |
|---|---|---|---|
Constant ("all") |
Total order across everything | 1 consumer, effectively | Almost never |
| Per tenant | Ordered within a tenant | One per tenant | Multi-tenant, cross-entity ordering matters |
| Per account or entity | Ordered per account | One per account | The usual right answer |
| Per message (unique) | None | Unlimited | You did not need FIFO |
# The narrowest ordering unit the business actually requires.
MessageGroupId=event["account_id"] # ledger entries per account stay ordered
FIFO queues cap at 300 transactions per second per API action, or 3,000 with batching of 10. High-throughput mode raises this substantially but requires many distinct group IDs to use it — another reason to pick a narrow ordering unit.
Step 4 — Add the consumer-side table
from psycopg.errors import UniqueViolation
def handle_batch(messages, conn) -> None:
for msg in messages:
event = json.loads(msg["Body"])
try:
with conn.transaction():
conn.execute(
"""INSERT INTO processed_events (consumer_group, event_id, source)
VALUES (%s, %s, 'sqs')""",
(GROUP, event["id"]),
)
apply_effect(event, conn) # same transaction as the marker
outcome = "processed"
except UniqueViolation:
outcome = "duplicate" # a redelivery — safe to delete
# Delete AFTER the transaction commits, never before.
sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=msg["ReceiptHandle"])
metrics.inc("sqs_messages_handled_total", outcome=outcome)
Delete after the commit, not before. Deleting first turns a crash into lost data; deleting after turns it into a redelivery that the marker table absorbs — which is the trade you want.
Size the visibility timeout above the handler’s p99 duration, with headroom:
# Extend the visibility while a long handler is still working, rather than
# setting a single huge timeout that delays recovery from a real crash.
def heartbeat(receipt_handle, every_s=20, extend_to_s=60):
while not done.is_set():
sqs.change_message_visibility(
QueueUrl=QUEUE_URL, ReceiptHandle=receipt_handle,
VisibilityTimeout=extend_to_s)
done.wait(every_s)
Step 5 — Verify both layers
Confirm the broker deduplicates inside the window.
ID=$(uuidgen)
for i in 1 2; do
aws sqs send-message --queue-url "$QUEUE_URL" --message-body '{"id":"'"$ID"'"}' \
--message-deduplication-id "$ID" --message-group-id acct_7 \
--query MessageId --output text
done
# expect: the SAME MessageId twice — the second send was discarded
Confirm it does not deduplicate outside the window.
aws sqs send-message --queue-url "$QUEUE_URL" --message-body '{"id":"'"$ID"'"}' \
--message-deduplication-id "$ID" --message-group-id acct_7 --query MessageId --output text
sleep 310 # just past 5 minutes
aws sqs send-message --queue-url "$QUEUE_URL" --message-body '{"id":"'"$ID"'"}' \
--message-deduplication-id "$ID" --message-group-id acct_7 --query MessageId --output text
# expect: two DIFFERENT MessageIds — only your table stops the double effect
psql -c "SELECT count(*) FROM ledger_entries WHERE event_id = '$ID'" # expect 1
Confirm redelivery is absorbed.
# Force a visibility-timeout redelivery by killing the consumer mid-handler.
kill -STOP "$(pgrep -f sqs-consumer)" # freeze past the visibility timeout
sleep 45
kill -CONT "$(pgrep -f sqs-consumer)"
psql -c "SELECT event_id, count(*) FROM ledger_entries GROUP BY 1 HAVING count(*) > 1"
# expect: zero rows
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
| Content-based deduplication enabled, and two genuinely identical events collapse into one | Disable it and set an explicit MessageDeduplicationId derived from the producing event’s id. Backfill the missing event manually. |
Producer send count versus NumberOfMessagesSent; a persistent shortfall means silent collapsing |
A single constant MessageGroupId, so the queue processes one message at a time and lag grows without bound |
Change the group ID to the narrowest ordering unit — usually an account or entity id — and redeploy consumers. | ApproximateAgeOfOldestMessage climbing while consumers sit idle; NumberOfMessagesReceived flat at ~1 per poll |
| Visibility timeout shorter than the handler’s p99, so every slow message is redelivered and processed twice | Raise the timeout above p99 with headroom, or heartbeat with ChangeMessageVisibility. The marker table absorbs the effect, but the wasted work is real. |
sqs_messages_handled_total{outcome="duplicate"} rate above 1%; ApproximateReceiveCount above 1 |
DeleteMessage called before the database transaction commits, so a crash loses the event entirely |
Delete after the commit returns. A redelivery is recoverable; a deleted-but-unprocessed message is not. | events_lost_total from a producer-side sequence-gap detector; consumer lag that never recovers |
| Producer retries with a 10-minute backoff, so genuine duplicates land outside the 5-minute interval and rely entirely on the consumer table | This is expected, not a bug — but verify the marker table’s retention exceeds the producer’s total retry span. | processed_events_oldest_age_seconds compared against the producer’s documented give-up time |
SRE / observability checklist
sqs_messages_handled_total{outcome}— Counter splitprocessedandduplicate. A duplicate rate above 1% points at the visibility timeout, not at the producer.ApproximateAgeOfOldestMessage— CloudWatch gauge. Growth with idle consumers is the message-group-ID serialisation signature.ApproximateReceiveCount— Per-message attribute; log it. A p99 above 1 means messages are routinely being redelivered.ApproximateNumberOfMessagesNotVisible— Gauge of in-flight messages. A value pinned near the consumer count means handlers are slower than the visibility timeout.sqs_delete_after_commit_violations_total— Assertion counter from a test hook confirming deletion always follows the commit. Should be structurally zero.event_id,MessageIdandApproximateReceiveCountlog fields — On every handled message, so a duplicate can be attributed to the producer or to a redelivery.
Related
- Message Queue Deduplication Patterns — the parent page comparing broker-level and consumer-level deduplication.
- Deduplicating SQS and SNS Message Deliveries — the consumer-side mechanics in depth, including SNS fan-out.
- Kafka Transactions vs Consumer-Side Deduplication — the same boundary question on Kafka.
- Building an Idempotent Consumer with a Processed-Events Table — the marker table used in step 4.