Amazon S3 Object Annotations: Attach Rich Metadata Directly to Your Files

calendar_today July 25, 2026
person info@softcrony.com
folder DevOps

🕮6 min read · 1,104 words

Amazon S3 recently shipped a feature that sounds minor but changes how you think about file storage: Object Annotations. You can now attach rich, queryable context directly to S3 objects — and query across that context without touching the files themselves.

This is different from S3’s existing metadata in ways that matter. Here’s what changed and when it actually helps.

What S3 Metadata Could Do Before

S3 has always had two types of metadata you could attach to objects:

System metadata: Things S3 manages — Content-Type, Content-Length, Last-Modified, ETag. You read these; S3 sets them.

User-defined metadata: Custom key-value pairs you add when uploading. Limited to simple strings, limited total size, and critically — not queryable. If you wanted to find all objects with a specific metadata value, you had to list all objects and filter manually.

# Old way — add metadata on upload (strings only, not queryable)
aws s3 cp file.pdf s3://my-bucket/ \
  --metadata "client_id=C001,status=processed,uploaded_by=atul"

# To find all objects where client_id=C001 — you had to do this
aws s3api list-objects --bucket my-bucket | \
  jq '.Contents[].Key' | \
  xargs -I{} aws s3api head-object --bucket my-bucket --key {} | \
  # ... filter by metadata... horrible at scale

What Object Annotations Add

Annotations are a separate layer from metadata — structured, typed, and queryable:

  • Structured types: Not just strings — numbers, booleans, timestamps, arrays, nested objects
  • Larger payload: Up to 2KB per annotation set vs 2KB total for all user metadata
  • Queryable: S3 Select and S3 Inventory can filter and query based on annotations
  • Separate access control: You can grant access to read annotations without granting access to the object itself
  • Updatable independently: Update annotations without copying or replacing the object
# Attach annotations to an existing object
aws s3api put-object-annotations \
  --bucket my-bucket \
  --key documents/invoice-2026-001.pdf \
  --annotations '{
    "client_id": "C001",
    "client_name": "Agrawal Steel Industries",
    "document_type": "invoice",
    "amount": 245000,
    "currency": "INR",
    "status": "pending_approval",
    "due_date": "2026-08-15",
    "approver_id": "user_42",
    "tags": ["gst", "b2b", "steel"],
    "processed": false
  }'
# Query objects by annotation — no Lambda, no DynamoDB index needed
aws s3api list-objects-with-annotations \
  --bucket my-bucket \
  --annotation-filter '{
    "status": {"eq": "pending_approval"},
    "amount": {"gt": 100000},
    "due_date": {"lte": "2026-08-01"}
  }'

What This Replaces

Before Annotations, the common pattern for “find S3 files by business context” was:

  1. Store file in S3
  2. Store file metadata in DynamoDB or RDS with the S3 key
  3. Query the database to find relevant keys
  4. Fetch from S3 using those keys

This works but adds architectural complexity — you now have two systems that need to stay in sync. If you update the file (re-upload), you need to update the database record. If the database is inconsistent, you can end up with orphaned records or stale state.

Annotations keep the context with the file. The source of truth is the object itself.

Practical Use Cases

Document Management Systems

// Laravel — attach annotations when storing a document
use Aws\S3\S3Client;

class DocumentService
{
    public function __construct(private S3Client $s3) {}

    public function store(UploadedFile $file, array $context): string
    {
        $key = 'documents/' . Str::uuid() . '.' . $file->getClientOriginalExtension();

        // Upload the file
        $this->s3->putObject([
            'Bucket' => config('filesystems.disks.s3.bucket'),
            'Key'    => $key,
            'Body'   => $file->getContent(),
            'ContentType' => $file->getMimeType(),
        ]);

        // Attach rich annotations separately
        $this->s3->putObjectAnnotations([
            'Bucket' => config('filesystems.disks.s3.bucket'),
            'Key'    => $key,
            'Annotations' => [
                'client_id'     => $context['client_id'],
                'document_type' => $context['type'],
                'amount'        => $context['amount'] ?? null,
                'status'        => 'uploaded',
                'uploaded_by'   => auth()->id(),
                'uploaded_at'   => now()->toIso8601String(),
                'fiscal_year'   => date('Y') . '-' . (date('Y') + 1),
                'tags'          => $context['tags'] ?? [],
            ],
        ]);

        return $key;
    }

    public function findPendingApprovals(string $clientId): array
    {
        $result = $this->s3->listObjectsWithAnnotations([
            'Bucket' => config('filesystems.disks.s3.bucket'),
            'Prefix' => 'documents/',
            'AnnotationFilter' => [
                'client_id' => ['eq' => $clientId],
                'status'    => ['eq' => 'pending_approval'],
            ],
        ]);

        return $result['Objects'] ?? [];
    }
}

Media Asset Management

# Marketing team uploads images — annotate with campaign context
aws s3api put-object-annotations \
  --bucket assets-bucket \
  --key images/campaign-diwali-hero.jpg \
  --annotations '{
    "campaign": "diwali-2026",
    "brand": "softcrony",
    "format": "hero",
    "dimensions": {"width": 1920, "height": 1080},
    "color_profile": "sRGB",
    "approved": true,
    "approved_by": "design_lead",
    "expires_at": "2026-11-15",
    "usage_rights": ["web", "social", "email"]
  }'

# Find all approved hero images for Diwali campaign
aws s3api list-objects-with-annotations \
  --bucket assets-bucket \
  --annotation-filter '{
    "campaign": {"eq": "diwali-2026"},
    "format": {"eq": "hero"},
    "approved": {"eq": true}
  }'

Compliance and Audit Trails

# Attach processing audit trail to sensitive documents
aws s3api put-object-annotations \
  --bucket compliance-bucket \
  --key reports/financial-Q2-2026.xlsx \
  --annotations '{
    "classification": "confidential",
    "data_category": "financial",
    "retention_years": 7,
    "retention_until": "2033-07-25",
    "processing_log": [
      {"action": "created", "user": "cfo@company.com", "timestamp": "2026-07-25T09:00:00Z"},
      {"action": "reviewed", "user": "auditor@firm.com", "timestamp": "2026-07-25T14:30:00Z"}
    ],
    "dpdp_category": "financial_data",
    "deletion_request_pending": false
  }'

S3 Annotations vs DynamoDB — When to Use Each

Scenario Use Annotations Use DynamoDB
Context is specific to the file
Context is shared across files
Simple filtering queries
Complex joins across entities
Context must stay with object (compliance)
High-frequency updates to context ⚠️ Check pricing
Reducing architectural complexity

Pricing Considerations

S3 Annotations have their own request pricing — separate from standard S3 GET/PUT requests. Key points:

  • PutObjectAnnotations: priced per request (check current AWS pricing)
  • GetObjectAnnotations: separate from GetObject — you can read annotations without paying for object retrieval
  • ListObjectsWithAnnotations: billed differently from ListObjects
  • Annotation storage is separate from object storage cost

For most use cases — storing annotations on documents and querying them occasionally — the cost is negligible. For high-frequency annotation updates on millions of objects, model the costs carefully before adoption.

Setting This Up in Laravel with Flysystem

// config/filesystems.php — custom S3 client with annotations support
'disks' => [
    's3' => [
        'driver' => 's3',
        'key'    => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION', 'ap-south-1'), // Mumbai
        'bucket' => env('AWS_BUCKET'),
        'url'    => env('AWS_URL'),
        'endpoint' => env('AWS_ENDPOINT'),
    ],
],
// app/Services/S3AnnotationService.php
use Aws\S3\S3Client;

class S3AnnotationService
{
    public function __construct(
        private readonly S3Client $client
    ) {}

    public function annotate(string $key, array $annotations): void
    {
        $this->client->putObjectAnnotations([
            'Bucket'      => config('filesystems.disks.s3.bucket'),
            'Key'         => $key,
            'Annotations' => $annotations,
        ]);
    }

    public function getAnnotations(string $key): array
    {
        $result = $this->client->getObjectAnnotations([
            'Bucket' => config('filesystems.disks.s3.bucket'),
            'Key'    => $key,
        ]);

        return $result['Annotations'] ?? [];
    }

    public function updateStatus(string $key, string $status): void
    {
        // Update single annotation without fetching entire object
        $existing = $this->getAnnotations($key);
        $this->annotate($key, array_merge($existing, [
            'status'     => $status,
            'updated_at' => now()->toIso8601String(),
        ]));
    }
}

S3 Object Annotations is one of those AWS features that doesn’t look revolutionary in the announcement but quietly removes an entire class of architectural decisions from your applications. Worth knowing about before you design your next file storage system.

If you’re building document management, asset pipelines, or compliance systems on AWS and need architecture advice, our team at Softcrony can help you design the right approach.

Leave a comment