Amazon SQS #
Amazon Simple Queue Service (SQS) is AWS’s fully managed message queuing service — no servers to manage, no capacity to provision, and automatically scalable to billions of messages per day. SQS is a great fit if you’re already in the AWS ecosystem and want async integration between Lambda, EC2, ECS, or other services without managing your own broker infrastructure. Understanding the two queue types (Standard and FIFO), how Visibility Timeout works, and the correct consumer loop pattern is key to avoiding messages that get processed twice or lost.
SQS Basic Concepts #
flowchart LR
subgraph Producers ["Producer"]
A["App A"]
B["App B"]
end
subgraph SQS ["SQS Queue"]
Q["[msg1][msg2][msg3]\n(messages stored in AWS, managed)"]
end
subgraph Consumers ["Consumer"]
W1["Worker 1"]
W2["Worker 2"]
end
A --> |send_message| Q
B --> |send_message| Q
Q --> |poll| W1
Q --> |poll| W2The two queue types: #
Standard Queue
- Unlimited throughput
- At-least-once delivery (messages can be delivered more than once)
- Best-effort ordering (order not guaranteed)
- Good for: task queues, notifications, idempotent processing
FIFO Queue (the name must end with
.fifo)- Limited throughput (3,000/second with batching, 300/second without)
- Exactly-once delivery (no duplicates)
- Strict ordering per Message Group ID
- Good for: financial transactions, critical event ordering
Visibility Timeout: #
When a message is received, it’s “hidden” from other consumers for N seconds. If it isn’t deleted within that time, the message reappears in the queue and can be processed again.
flowchart TD
Q["Queue"] --> |receive| W["Worker processes"]
W --> |delete| Del["Message gone from the queue"]
W -. "if crash or timeout (message reappears)" .-> QInstallation and Authentication #
pip install boto3
AWS requires credentials for every API call. There are several authentication methods — in priority order:
1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
2. AWS credentials file (~/.aws/credentials)
3. AWS IAM Role (for EC2, Lambda, ECS — the best way in production)
4. Explicit parameters in code (NEVER do this)
import boto3
import os
# ANTI-PATTERN: hardcoding credentials in code
sqs = boto3.client(
"sqs",
aws_access_key_id="«redacted:AKIA…»", # ✗ -- never do this
aws_secret_access_key="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
)
# CORRECT way 1: read from environment variables
sqs = boto3.client(
"sqs",
region_name=os.getenv("AWS_DEFAULT_REGION", "ap-southeast-1"),
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
aws_session_token=os.getenv("AWS_SESSION_TOKEN") # if using temporary credentials
)
# CORRECT way 2: boto3 automatically reads from ~/.aws/credentials or the IAM Role
sqs = boto3.client("sqs", region_name="ap-southeast-1")
# CORRECT way 3: use the resource API (more pythonic)
sqs_resource = boto3.resource("sqs", region_name="ap-southeast-1")
In production on EC2, ECS, or Lambda, use an IAM Role attached to the compute resource — no need to store credentials at all. Boto3 automatically fetches credentials from the instance metadata service. This is the safest approach because credentials are rotated automatically.
Creating Queues #
import boto3
import json
import os
sqs = boto3.client("sqs", region_name=os.getenv("AWS_DEFAULT_REGION", "ap-southeast-1"))
def buat_standard_queue(nama: str, retention_detik: int = 86400) -> str:
"""Create a Standard Queue, return the URL."""
response = sqs.create_queue(
QueueName=nama,
Attributes={
"VisibilityTimeout": "30", # seconds a message is hidden while processed
"MessageRetentionPeriod": str(retention_detik), # how long messages are kept (max 14 days)
"ReceiveMessageWaitTimeSeconds": "20", # Long Polling -- wait up to 20 seconds
"RedrivePolicy": json.dumps({ # Dead Letter Queue after 3 failures
"deadLetterTargetArn": buat_dlq(nama + "-dlq"),
"maxReceiveCount": "3"
})
}
)
print(f"Standard Queue created: {response['QueueUrl']}")
return response["QueueUrl"]
def buat_fifo_queue(nama: str) -> str:
"""Create a FIFO Queue -- the name must end with .fifo"""
if not nama.endswith(".fifo"):
nama += ".fifo"
response = sqs.create_queue(
QueueName=nama,
Attributes={
"FifoQueue": "true",
"ContentBasedDeduplication": "true", # auto-dedup based on message content
"VisibilityTimeout": "60",
"MessageRetentionPeriod": "86400",
"ReceiveMessageWaitTimeSeconds": "20",
}
)
print(f"FIFO Queue created: {response['QueueUrl']}")
return response["QueueUrl"]
def buat_dlq(nama: str) -> str:
"""Create a Dead Letter Queue, return the ARN."""
response = sqs.create_queue(
QueueName=nama,
Attributes={
"MessageRetentionPeriod": str(14 * 24 * 3600) # keep for 14 days
}
)
url = response["QueueUrl"]
# Get the ARN of the newly created queue
attrs = sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["QueueArn"])
return attrs["Attributes"]["QueueArn"]
def ambil_queue_url(nama: str) -> str:
"""Get the URL of an existing queue."""
response = sqs.get_queue_url(QueueName=nama)
return response["QueueUrl"]
Sending Messages #
import json
from datetime import datetime, timezone
import uuid
QUEUE_URL = ambil_queue_url("order-queue")
def kirim_pesan(queue_url: str, payload: dict, delay_detik: int = 0) -> str:
"""Send one message to a Standard Queue."""
body = json.dumps(payload, ensure_ascii=False)
response = sqs.send_message(
QueueUrl=queue_url,
MessageBody=body,
DelaySeconds=delay_detik, # delay delivery (0–900 seconds)
MessageAttributes={
"source": {
"DataType": "String",
"StringValue": "order-service"
},
"event_type": {
"DataType": "String",
"StringValue": payload.get("event", "unknown")
}
}
)
msg_id = response["MessageId"]
print(f"Message sent: {msg_id}")
return msg_id
def kirim_pesan_fifo(queue_url: str, payload: dict, group_id: str) -> str:
"""Send a message to a FIFO Queue with a Message Group ID."""
body = json.dumps(payload, ensure_ascii=False)
response = sqs.send_message(
QueueUrl=queue_url,
MessageBody=body,
MessageGroupId=group_id, # messages in the same group are guaranteed ordered
MessageDeduplicationId=str(uuid.uuid4()) # unique ID to prevent duplicates
)
return response["MessageId"]
# Usage example
order = {
"event": "order.created",
"order_id": 1001,
"user_id": 42,
"total": 18500000,
"timestamp": datetime.now(timezone.utc).isoformat()
}
kirim_pesan(QUEUE_URL, order)
# FIFO -- messages per user_id are guaranteed ordered
kirim_pesan_fifo(
ambil_queue_url("order-fifo.fifo"),
order,
group_id=f"user-{order['user_id']}" # all orders for user 42 processed in order
)
Batch Send — More Efficient #
def kirim_batch(queue_url: str, payloads: list[dict]) -> dict:
"""
Send up to 10 messages at once.
More efficient than sending one by one (AWS billing is per request).
"""
entries = [
{
"Id": str(i),
"MessageBody": json.dumps(p, ensure_ascii=False),
"MessageAttributes": {
"event_type": {
"DataType": "String",
"StringValue": p.get("event", "unknown")
}
}
}
for i, p in enumerate(payloads[:10]) # max 10 per batch
]
response = sqs.send_message_batch(QueueUrl=queue_url, Entries=entries)
sukses = len(response.get("Successful", []))
gagal = len(response.get("Failed", []))
if response.get("Failed"):
for f in response["Failed"]:
print(f"Failed to send ID {f['Id']}: {f['Message']}")
print(f"Batch sent: {sukses} success, {gagal} failed")
return response
# Send 10 orders at once
orders = [{"event": "order.created", "order_id": i, "total": i * 10000} for i in range(1, 11)]
kirim_batch(QUEUE_URL, orders)
Consumer Loop #
SQS uses a pull model — consumers must actively request messages. Use Long Polling (WaitTimeSeconds=20) to reduce cost and latency compared to Short Polling.
import signal
import json
import time
def proses_order(payload: dict) -> None:
"""Business logic -- if an exception is raised, the message isn't deleted."""
print(f"Processing order #{payload['order_id']} — Rp{payload['total']:,.0f}")
# ... save to the database, send notifications, etc.
def jalankan_consumer(queue_url: str) -> None:
berjalan = True
def handle_shutdown(signum, frame):
nonlocal berjalan
print("Shutdown signal, stopping consumer...")
berjalan = False
signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)
print(f"Consumer active, polling from the queue...")
while berjalan:
try:
# Long Polling: wait up to 20 seconds if the queue is empty
# Far more efficient than rapid polling (reduces API call cost)
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=10, # fetch up to 10 messages per request
WaitTimeSeconds=20, # Long Polling
VisibilityTimeout=30, # override the visibility timeout for this batch
MessageAttributeNames=["All"],
AttributeNames=["All"]
)
messages = response.get("Messages", [])
if not messages:
continue # empty queue, poll again
for msg in messages:
receipt_handle = msg["ReceiptHandle"]
msg_id = msg["MessageId"]
try:
payload = json.loads(msg["Body"])
proses_order(payload)
# REQUIRED: delete after successful processing
# If not deleted, the message reappears after the VisibilityTimeout
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle
)
print(f"✓ Message {msg_id} processed and deleted.")
except json.JSONDecodeError as e:
print(f"✗ Invalid message format: {e}")
# Delete the invalid message -- retrying won't fix it
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle)
except Exception as e:
print(f"✗ Error processing {msg_id}: {e}")
# DON'T delete -- let the VisibilityTimeout expire
# The message will reappear to be processed again
# After maxReceiveCount failures, it goes to the DLQ
except Exception as e:
print(f"Error polling: {e}")
time.sleep(5) # pause before retrying on network errors
print("Consumer stopped.")
jalankan_consumer(QUEUE_URL)
Always calldelete_message()after successful processing. If you don’t, the message reappears in the queue after theVisibilityTimeoutexpires and gets processed again by another consumer. Conversely, don’t delete messages that failed processing — let SQS handle them through the retry mechanism and Dead Letter Queue.
Batch Delete — High Efficiency #
def consumer_batch_delete(queue_url: str) -> None:
"""
Consumer with batch delete -- more efficient for high throughput.
Process all messages, collect the successful ones, delete at once.
"""
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=10,
WaitTimeSeconds=20
)
messages = response.get("Messages", [])
if not messages:
return
berhasil_dihapus = []
for msg in messages:
try:
payload = json.loads(msg["Body"])
proses_order(payload)
berhasil_dihapus.append({
"Id": msg["MessageId"],
"ReceiptHandle": msg["ReceiptHandle"]
})
except Exception as e:
print(f"✗ Failed to process {msg['MessageId']}: {e}")
# Not added to berhasil_dihapus -- will retry automatically
# Batch delete for the successful messages
if berhasil_dihapus:
sqs.delete_message_batch(
QueueUrl=queue_url,
Entries=berhasil_dihapus
)
print(f"Batch delete: {len(berhasil_dihapus)} messages deleted.")
Dead Letter Queues — Monitoring Failed Messages #
def proses_dlq(dlq_url: str, kirim_alert: bool = True) -> None:
"""
Read messages from the Dead Letter Queue for monitoring and debugging.
Messages in the DLQ are those that failed maxReceiveCount times.
"""
response = sqs.receive_message(
QueueUrl=dlq_url,
MaxNumberOfMessages=10,
WaitTimeSeconds=5,
AttributeNames=["All"]
)
messages = response.get("Messages", [])
if not messages:
print("DLQ is empty.")
return
print(f"⚠ {len(messages)} messages in the DLQ:")
for msg in messages:
attrs = msg.get("Attributes", {})
receive_count = attrs.get("ApproximateReceiveCount", "?")
try:
payload = json.loads(msg["Body"])
except Exception:
payload = msg["Body"]
print(f" ID: {msg['MessageId']}")
print(f" Attempted: {receive_count}x")
print(f" Payload: {payload}")
if kirim_alert:
# Send a notification to the team (Slack, email, PagerDuty, etc.)
print(f" → Alert sent for message {msg['MessageId']}")
# After analysis, you can:
# 1. Delete messages from the DLQ (if unrecoverable)
# 2. Move them back to the main queue after the bug is fixed (redrive)
def redrive_dari_dlq(dlq_url: str, main_queue_url: str) -> int:
"""Move messages from the DLQ back to the main queue after the bug is fixed."""
dipindah = 0
while True:
response = sqs.receive_message(
QueueUrl=dlq_url,
MaxNumberOfMessages=10,
WaitTimeSeconds=1
)
messages = response.get("Messages", [])
if not messages:
break
for msg in messages:
sqs.send_message(QueueUrl=main_queue_url, MessageBody=msg["Body"])
sqs.delete_message(QueueUrl=dlq_url, ReceiptHandle=msg["ReceiptHandle"])
dipindah += 1
print(f"{dipindah} messages moved from the DLQ to the main queue.")
return dipindah
Visibility Timeout — Extending for Long Processing #
def proses_dengan_extend_visibility(queue_url: str) -> None:
"""
For messages that need long processing time,
extend the visibility timeout periodically so they don't reappear.
"""
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=1,
WaitTimeSeconds=20,
VisibilityTimeout=30 # give 30 initial seconds
)
messages = response.get("Messages", [])
if not messages:
return
msg = messages[0]
receipt_handle = msg["ReceiptHandle"]
try:
payload = json.loads(msg["Body"])
print(f"Processing a large message: {msg['MessageId']}")
for langkah in range(5):
# Simulate a long process
time.sleep(20)
# Extend the visibility timeout before it expires
sqs.change_message_visibility(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle,
VisibilityTimeout=30 # add 30 more seconds
)
print(f"Visibility timeout extended, step {langkah + 1}/5")
# Done -- delete the message
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle)
print("Message processed successfully.")
except Exception as e:
print(f"Error: {e}")
# Don't extend -- let the timeout expire and the message reappear
Summary #
- Standard vs FIFO — use FIFO (name must end with
.fifo) if message ordering and exactly-once delivery are critical; use Standard for high throughput with idempotent processing.- Long Polling is required — always set
WaitTimeSeconds=20inreceive_message(); it reduces API call cost and latency compared to Short Polling, which keeps polling even when the queue is empty.- Delete on success, not on failure — call
delete_message()only after successful processing; if it fails, let theVisibilityTimeoutexpire so the message can be reprocessed.- Visibility Timeout must be longer than the processing time — set it larger than the estimated processing duration; use
change_message_visibility()to extend it if processing takes long.- Dead Letter Queue (DLQ) — always configure a DLQ with the right
maxReceiveCountso repeatedly failing messages don’t block the main queue.- Batch send and batch delete — use
send_message_batch()anddelete_message_batch()for efficiency; SQS billing is per API request, not per message.MaxNumberOfMessages=10— fetch up to 10 messages per poll; more efficient than one at a time.- IAM Roles in production — don’t store AWS credentials in code or environment variables on EC2/ECS/Lambda; use an IAM Role attached to the compute resource.
- Message Attributes — use them for metadata (event type, source service) that needs filtering without parsing the message body.
- Idempotent consumers — design consumers to be safe to run more than once for the same message, because Standard Queues guarantee at-least-once (not exactly-once) delivery.