Photo by Victor Zissou on Pexels
S3 object tagging with AWS SDK v3 works two ways. At upload, pass Tagging to PutObjectCommand as a URL-encoded string such as project=billing&owner=data%20team. On an existing object, send PutObjectTaggingCommand with a TagSet, which replaces every tag on the object. Read tags with GetObjectTaggingCommand; remove them with DeleteObjectTaggingCommand.
This guide is for Node.js and TypeScript developers who label S3 objects for access control, lifecycle rules or cost reporting: a classification tag set at upload, a retention tag added by a nightly job, a project tag applied to a whole prefix. You’ll get the upload form, a small module that merges tags without wiping the existing ones, version-specific tagging, a bulk tagger with bounded concurrency, IAM and lifecycle examples, the limits and the cost.
Every sample was type-checked with strict tsc against @aws-sdk/client-s3 3.1142.0 and run in September 2026 against a mocked S3 client.
Which S3 tagging call should you use?
| You want to… | Use | Notes |
|---|---|---|
| Tag a new object | PutObjectCommand with Tagging |
One URL-query-encoded string. Also works on CreateMultipartUploadCommand. |
| Set or change tags on an existing object | PutObjectTaggingCommand |
Replaces the whole tag set. To add one tag, read, merge and write. |
| Read tags | GetObjectTaggingCommand |
GetObject only returns a count (TagCount, from the x-amz-tag-count header). |
| Remove all tags | DeleteObjectTaggingCommand |
AWS prefers this to a PutObjectTagging call with an empty set, which is billed as a PUT request. |
| Copy an object with or without its tags | CopyObjectCommand |
TaggingDirective: "COPY" (default) keeps them; "REPLACE" uses Tagging instead. |
Object tagging isn’t supported for directory buckets (S3 Express One Zone). Tags are strongly consistent, so a GetObjectTagging right after a write returns the new set.
What are the S3 object tag limits?
- Up to 10 tags per object, each with a unique key.
- Keys up to 128 Unicode characters, values up to 256. S3 stores tags in UTF-16, where some characters take 2 positions; JavaScript’s
String.lengthcounts UTF-16 code units the same way, which is why the module below validates with it. - Keys and values are case sensitive:
Projectandprojectare different tags. - Tags label data; they shouldn’t contain it. The S3 User Guide says tags can mark objects holding PII or PHI, but the tags themselves shouldn’t be confidential.
Need more than 10 labels or longer values? The same guide points to S3 object annotations for richer metadata.
Prerequisites
- Node.js 18 or later, a project with
"type": "module"(the samples use top-levelawait),@aws-sdk/client-s3andtsx. - Credentials the SDK can resolve; AWS SDK v3 credential providers explains the chain.
- A general purpose bucket you can write to. Uploading is covered in depth in uploading a file to S3 with S3Client in TypeScript.
S3 object tagging with AWS SDK v3, step by step
- Decide the tag keysAgree a short list (
project,classification,retention) so IAM conditions and lifecycle filters can rely on them. - Tag at upload when you canBuild
TaggingwithencodeURIComponenton every key and value. It costs no extra request. - Merge, don’t overwriteFor existing objects, read the current set with
GetObjectTagging, change it, and write the full set back withPutObjectTagging. - Target versions deliberatelyWithout
VersionId, the calls act on the current version. Pass it to tag an older version. - Grant the matching permissions
s3:PutObjectTaggingands3:GetObjectTagging, plus the...VersionTaggingactions when you pass a version ID.
Example: tag an object at upload
// put-object-with-tags.ts: upload an object and tag it in the same PutObject request.
// Usage: BUCKET=acme-reports npx tsx put-object-with-tags.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const tags: Record<string, string> = { project: "billing", classification: "internal", owner: "data team" };
// PutObject takes tags as one URL-query-encoded string: "project=billing&owner=data%20team"
const Tagging = Object.entries(tags)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const out = await s3.send(new PutObjectCommand({
Bucket: process.env.BUCKET ?? "acme-reports",
Key: "2027/08/invoice-summary.csv",
Body: "month,total\n2027-07,1834.20\n",
ContentType: "text/csv",
Tagging,
}));
console.log(`Uploaded with tags: ${Tagging}`);
console.log(`ETag ${out.ETag}${out.VersionId ? `, version ${out.VersionId}` : ""}`);
Uploaded with tags: project=billing&classification=internal&owner=data%20team
ETag "9b2cf535f27731c974343645a3985328", version 3HL4kqtJlcpXroDTDmJ.rmSpXd3dIbrHY
The API reference only says the string must be encoded as URL query parameters. encodeURIComponent turns the space in data team into %20 and escapes & and = inside values, which would otherwise split the string in the wrong place. The output is from a mocked client. Adding Tagging to an upload also requires s3:PutObjectTagging; the guide to uploading large files and streams to S3 shows how lib-storage applies tags to multipart uploads.
Example: merge and remove tags without losing the others
PutObjectTagging is a replace. Sending { retention: "7y" } to an object that already has project and owner tags leaves it with only retention. This module wraps the read-merge-write pattern and validates limits before calling S3:
// object-tags.ts: read, replace, merge and remove S3 object tags with AWS SDK v3.
// Every function takes an optional versionId to work on a specific object version.
import {
S3Client,
DeleteObjectTaggingCommand,
GetObjectTaggingCommand,
PutObjectTaggingCommand,
type Tag,
} from "@aws-sdk/client-s3";
export type Tags = Record<string, string>;
export interface ObjectRef { bucket: string; key: string; versionId?: string }
const MAX_TAGS = 10;
/** Throws before calling S3 when the set breaks the documented limits. */
export function validateTags(tags: Tags): void {
const entries = Object.entries(tags);
if (entries.length > MAX_TAGS) throw new Error(`${entries.length} tags: S3 allows at most ${MAX_TAGS} per object`);
for (const [k, v] of entries) {
// S3 measures tags in UTF-16 code units, which is what String.length counts
if (k.length < 1 || k.length > 128) throw new Error(`tag key "${k}" must be 1 to 128 characters`);
if (v.length > 256) throw new Error(`value of tag "${k}" is longer than 256 characters`);
}
}
const toTagSet = (tags: Tags): Tag[] => Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
export async function getTags(s3: S3Client, o: ObjectRef): Promise<Tags> {
const out = await s3.send(new GetObjectTaggingCommand({ Bucket: o.bucket, Key: o.key, VersionId: o.versionId }));
return Object.fromEntries((out.TagSet ?? []).map((t) => [t.Key ?? "", t.Value ?? ""]));
}
/** Replaces the whole tag set: any tag not in `tags` is removed. */
export async function replaceTags(s3: S3Client, o: ObjectRef, tags: Tags): Promise<void> {
validateTags(tags);
await s3.send(new PutObjectTaggingCommand({
Bucket: o.bucket, Key: o.key, VersionId: o.versionId, Tagging: { TagSet: toTagSet(tags) },
}));
}
/** Adds or overwrites some tags and keeps the rest (read, merge, write). Returns the new set. */
export async function mergeTags(s3: S3Client, o: ObjectRef, changes: Tags): Promise<Tags> {
const merged = { ...(await getTags(s3, o)), ...changes };
await replaceTags(s3, o, merged);
return merged;
}
/** Removes the named keys. Uses DeleteObjectTagging when nothing is left. */
export async function removeTags(s3: S3Client, o: ObjectRef, keys: string[]): Promise<Tags> {
const remaining = await getTags(s3, o);
for (const k of keys) delete remaining[k];
if (Object.keys(remaining).length === 0) await clearTags(s3, o);
else await replaceTags(s3, o, remaining);
return remaining;
}
export async function clearTags(s3: S3Client, o: ObjectRef): Promise<void> {
await s3.send(new DeleteObjectTaggingCommand({ Bucket: o.bucket, Key: o.key, VersionId: o.versionId }));
}
// use-object-tags.ts: merge a tag into an object, show the result, remove it again.
// Usage: BUCKET=acme-reports npx tsx use-object-tags.ts
import { S3Client } from "@aws-sdk/client-s3";
import { getTags, mergeTags, removeTags } from "./object-tags.js";
const s3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const obj = { bucket: process.env.BUCKET ?? "acme-reports", key: "2027/08/invoice-summary.csv" };
console.log("before:", await getTags(s3, obj));
console.log("merged:", await mergeTags(s3, obj, { retention: "7y", classification: "confidential" }));
console.log("removed:", await removeTags(s3, obj, ["retention"]));
before: { project: 'billing', classification: 'internal', owner: 'data team' }
merged: {
project: 'billing',
classification: 'confidential',
owner: 'data team',
retention: '7y'
}
removed: {
project: 'billing',
classification: 'confidential',
owner: 'data team'
}
The merge isn’t atomic. PutObjectTagging has no condition parameter, so two jobs merging different tags into the same object at the same moment can lose one of the writes. Give each tag key a single owner process, or serialize writers per object. S3 can also return OperationAborted when a conflicting conditional action is in progress on the object; retry it.
Tagging a specific object version
In a versioned bucket, tags belong to a version. replaceTags(s3, { bucket, key, versionId }, tags) tags that version only, and the API reference requires s3:PutObjectVersionTagging for it (and s3:GetObjectVersionTagging to read). A new upload creates a new version with its own tags, so tags from the old version don’t carry forward unless you send them again.
Example: tag every object under a prefix
For thousands of objects, run a fixed number of workers over a paginated listing. This script merges tags into every object under a prefix with 16 requests in flight by default, reports failures instead of stopping, and is a dry run until you pass --apply:
// tag-prefix.ts: merge tags into every object under a prefix, with a fixed number of parallel requests.
// Dry run by default; --apply writes. Each object costs one GET and one PUT tagging request.
// Usage: BUCKET=acme-reports npx tsx tag-prefix.ts 2027/ project=billing retention=7y [--concurrency 16] [--apply]
import { S3Client, paginateListObjectsV2 } from "@aws-sdk/client-s3";
import { mergeTags, validateTags, type Tags } from "./object-tags.js";
const args = process.argv.slice(2);
const apply = args.includes("--apply");
const ci = args.indexOf("--concurrency");
const concurrency = ci >= 0 ? Number(args[ci + 1]) : 16;
const positional = args.filter((a, i) => !a.startsWith("--") && (ci < 0 || i !== ci + 1));
const [prefix = "", ...pairs] = positional;
const changes: Tags = Object.fromEntries(pairs.map((p) => {
const eq = p.indexOf("=");
if (eq < 1) throw new Error(`expected key=value, got "${p}"`);
return [p.slice(0, eq), p.slice(eq + 1)];
}));
validateTags(changes);
const bucket = process.env.BUCKET ?? "acme-reports";
const s3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1", maxAttempts: 5 });
async function* keys(): AsyncGenerator<string> {
for await (const page of paginateListObjectsV2({ client: s3 }, { Bucket: bucket, Prefix: prefix })) {
for (const o of page.Contents ?? []) if (o.Key && !o.Key.endsWith("/")) yield o.Key;
}
}
let done = 0;
const failed: string[] = [];
const source = keys();
async function worker(): Promise<void> {
for (let next = await source.next(); !next.done; next = await source.next()) {
const key = next.value;
try {
if (apply) await mergeTags(s3, { bucket, key }, changes);
done += 1;
if (done % 1000 === 0) console.log(`${done} objects ${apply ? "tagged" : "matched"}`);
} catch (err) {
failed.push(`${key}: ${err instanceof Error ? err.name : String(err)}`);
}
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()));
console.log(`${apply ? "Tagged" : "Would tag"} ${done} objects under s3://${bucket}/${prefix} with ${JSON.stringify(changes)}`);
if (failed.length > 0) {
console.log(`${failed.length} failed:`);
for (const f of failed.slice(0, 20)) console.log(` ${f}`);
process.exitCode = 1;
}
1000 objects tagged
2000 objects tagged
Tagged 2339 objects under s3://acme-reports/2027/ with {"project":"billing","retention":"7y"}
1 failed:
2027/events/part-0007.json: AccessDenied
The mocked run tagged 2,339 of 2,340 objects and reported the one the mock denied. All workers pull from one async generator, so each key is processed once and memory stays flat however large the prefix. The listing follows the same pattern as listing every object in an S3 bucket with SDK v3, and AWS SDK v3 paginators explains paginateListObjectsV2. maxAttempts: 5 gives the SDK’s retry strategy more room; SlowDown, the error S3 returns when you push too hard, is on its list of throttling codes (see AWS SDK v3 retries and timeouts).
For millions of objects, S3 Batch Operations is the managed alternative: you give it a list of objects and it applies a tag set to each, with progress tracking and a completion report. The September 2026 Price List shows $0.25 per job and $1.00 per million object operations for Batch Operations, so compare that with the request cost below.
How do tags control access and lifecycle?
S3 supports three tag condition keys. s3:ExistingObjectTag/<key> checks a tag already on the object; s3:RequestObjectTagKeys and s3:RequestObjectTag/<key> check the tags in a PutObject or PutObjectTagging request. This policy lets a role read only objects tagged classification=public and set only three agreed tag keys:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyPublicObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:GetObjectVersion"],
"Resource": "arn:aws:s3:::acme-reports/*",
"Condition": { "StringEquals": { "s3:ExistingObjectTag/classification": "public" } }
},
{
"Sid": "TagWithAgreedKeysOnly",
"Effect": "Allow",
"Action": ["s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging"],
"Resource": "arn:aws:s3:::acme-reports/*",
"Condition": {
"ForAllValues:StringEquals": { "s3:RequestObjectTagKeys": ["project", "classification", "retention"] }
}
},
{
"Sid": "ListForBulkTagging",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-reports"
}
]
}
Two caveats. The User Guide says s3:ExistingObjectTag isn’t supported for PutObject and DeleteObject, so you can’t protect an object from overwrite or deletion by its tags. And anyone who can change tags can change what a tag-based policy allows, so keep s3:PutObjectTagging narrow. Reviewing an IAM policy for least privilege covers the rest, and the IAM policy generator for TypeScript SDK code lists the actions your own scripts call.
Lifecycle rules can filter on tags too, alone or combined with a prefix. This rule expires objects tagged retention=30d after 30 days:
{
"Rules": [
{
"ID": "expire-30-day-retention",
"Status": "Enabled",
"Filter": { "Tag": { "Key": "retention", "Value": "30d" } },
"Expiration": { "Days": 30 }
}
]
}
Buckets with no rules at all show up in the script to find S3 buckets without lifecycle rules. Tags are also copied by S3 Replication when the replication role can read them, and s3:ObjectTagging:Put and s3:ObjectTagging:Delete event notifications fire when tags change.
What does S3 object tagging cost?
As of September 2026, the AWS Price List for Amazon S3 (published 26 September 2026) shows these rates in US East (N. Virginia):
| Item | Price |
|---|---|
| Tag storage | $0.0065 per 10,000 tags per month |
PutObjectTagging (PUT, COPY, POST, LIST tier) |
$0.005 per 1,000 requests |
GetObjectTagging (GET and other requests tier) |
$0.0004 per 1,000 requests |
Worked example: 2 million objects with 3 tags each is 6 million tags, or 6,000,000 ÷ 10,000 × $0.0065 = $3.90 a month. Adding a tag to all of them with the merge script costs one GET and one PUT per object: 2,000 × $0.0004 + 2,000 × $0.005 = $0.80 + $10.00 = $10.80, once. Tagging at upload avoids that second pass entirely, and S3 request cost explains the request tiers in more detail.
Troubleshooting and common mistakes
| Error or symptom | Usual cause |
|---|---|
InvalidTag |
The tag set failed S3’s input validation. Check it against the limits above: at most 10 tags, unique keys, and key and value lengths. |
MalformedXML |
The request body didn’t match the schema. With the SDK, check that you pass Tagging: { TagSet: [...] } with a Key and Value in every entry. |
AccessDenied |
Missing s3:PutObjectTagging (also needed for Tagging on upload), the ...VersionTagging actions for version IDs, or a tag condition that rejected the keys. See troubleshooting AWS IAM access denied errors. |
| Tags vanished after an update | A PutObjectTagging with only the new tag replaced the set. Use mergeTags. |
| Tags missing on a copied object | TaggingDirective: "REPLACE" without Tagging, or a role without s3:GetObjectTagging on the source; copying and moving S3 objects with SDK v3 covers the permissions. |
Coming from SDK v2, s3.putObjectTagging(params).promise() becomes client.send(new PutObjectTaggingCommand(params)) with the same parameter shape; the free AWS SDK v2 to v3 converter rewrites the rest of a file.
Frequently asked questions
How do I add a tag to an S3 object without removing the existing tags?
Read the current set with GetObjectTaggingCommand, add your tag to it, and write the full set with PutObjectTaggingCommand. PutObjectTagging always replaces the whole set.
How many tags can an S3 object have?
Up to 10, with unique keys of up to 128 Unicode characters and values of up to 256.
How do I pass tags to PutObjectCommand?
As a single URL-encoded query string in Tagging, for example "project=billing&owner=data%20team". Encode each key and value with encodeURIComponent.
Do S3 object tags cost money?
Yes. Tag storage is $0.0065 per 10,000 tags per month in US East (N. Virginia) as of September 2026, and PutObjectTagging calls are billed as PUT requests.
Can I tag many S3 objects in one request?
No single API call tags multiple objects. Use a concurrent script like the one above, or S3 Batch Operations for very large sets.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
