← All provider guides

AWS SDK v2 to v3 migration (JavaScript): what actually breaks and what a codemod can fix

aws sdk migration 22 tracked change records

AWS SDK for JavaScript v2 entered maintenance mode, and v3 is not a version bump — it is a different client model. Every service gets its own package, every operation becomes a command object, and the response types change shape. The migration touches every call site, which is exactly why teams postpone it, and exactly why most of it should be done by a codemod rather than by hand.

The four mechanical breakages

Strip away the marketing and the v2 to v3 migration for a service like S3 is four repeating rewrites. This list is read directly from the mendapi migration pack registry (aws-sdk-v2-to-v3-s3), so it matches what the codemod actually does:

  • Rewrite s3.<op>(params).promise() to s3.send(new <Op>Command(params))
  • Rewrite response Body.toString() to Body.transformToString() (v3 Body is a stream)
  • Replace new AWS.S3() client with new S3Client()
  • Replace aws-sdk require with modular @aws-sdk/client-s3 imports (only when no other AWS.* usage remains)

The first one is the bulk of the work: s3.getObject(params).promise() becomes s3.send(new GetObjectCommand(params)), once per call site, across the whole repo. The second is the one that passes review and fails in production: v3 response bodies are streams, so Body.toString() returns [object Object] instead of your file — the fix is Body.transformToString(), and nothing in the type of a quick edit warns you.

What a codemod should refuse to touch

A migration tool earns trust by what it leaves alone. The mendapi pack rewrites the require('aws-sdk') line into modular @aws-sdk/client-s3 imports only when no other AWS.* usage remains in the file — if your file also builds a DynamoDB client, the import line stays and the report says so. Guessing there would produce a broken build; staying silent is the correct verdict. The same fail-closed policy runs through every mendapi pack: each rule is locked by golden-fixture regression tests with negative controls, so a rule that starts over-firing fails our build before it reaches your repo.

Preview the whole migration as one diff

The pack runs locally as a dry run by default. On the bundled v2 fixture repo it rewrites two files across seven rule hits and writes a unified patch you can read before anything is applied:

npx mendapi sync
# -> one network call: pulls the change feed into a local database
npx mendapi scan
# -> which files touch aws-sdk, with line numbers
mendapi fix --migration aws-sdk-v2-to-v3-s3
# -> dry run: changes.patch + fix-report.json, nothing applied
mendapi fix --migration aws-sdk-v2-to-v3-s3 --apply
# -> applies the reviewed diff

Nothing leaves your machine at any step — zero network calls is the default and only mode. The report records which rule fired where, so review is a read of the patch, not an audit of the tool.

The diff itself, byte for byte

This is the actual changes.patch the dry run above produces on the bundled v2 fixture — embedded here from the golden regression evidence at page build time, not retyped. Both files are rewritten in full: the namespace client becomes a modular S3Client, every .promise() call becomes a command object send, and the Body.toString() footgun becomes Body.transformToString() in the same hunk.

--- a/index.js
+++ b/index.js
@@ -1,15 +1,15 @@
 // Legacy AWS SDK v2 usage (S3): namespace client + promise-returning call style.
-const AWS = require('aws-sdk');
+const { GetObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3');
 
-const s3 = new AWS.S3({ region: 'us-east-1' });
+const s3 = new S3Client({ region: 'us-east-1' });
 
 async function fetchReport(bucket, key) {
-  const res = await s3.getObject({ Bucket: bucket, Key: key }).promise();
-  return res.Body.toString('utf8');
+  const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
+  return res.Body.transformToString('utf8');
 }
 
 async function saveReport(bucket, key, body) {
-  await s3.putObject({ Bucket: bucket, Key: key, Body: body }).promise();
+  await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body }));
   return true;
 }
 
--- a/lib/storage.js
+++ b/lib/storage.js
@@ -1,15 +1,15 @@
 // Legacy AWS SDK v2 usage (S3): default client, list/delete operations.
-const AWS = require('aws-sdk');
+const { DeleteObjectCommand, ListObjectsV2Command, S3Client } = require('@aws-sdk/client-s3');
 
-const client = new AWS.S3();
+const client = new S3Client();
 
 async function listReports(bucket) {
-  const out = await client.listObjectsV2({ Bucket: bucket, Prefix: 'reports/' }).promise();
+  const out = await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: 'reports/' }));
   return (out.Contents || []).map((o) => o.Key);
 }
 
 async function removeReport(bucket, key) {
-  await client.deleteObject({ Bucket: bucket, Key: key }).promise();
+  await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
 }
 
 module.exports = { listReports, removeReport };

The part no codemod fixes

Honest scope: the pack handles the mechanical rewrites. It does not restructure code that depends on v2-only behavior — global configuration via AWS.config, implicit credential chains that v3 resolves differently, or code that passes clients across module boundaries and mixes services in one file. Those need a human decision, and the tracked AWS change history (6 breaking or deprecation entries in the database) shows the same split: runtime and platform requirement changes stay not code-fixable by verdict, because pretending a codemod covers them is how migrations go wrong.

Related

Every entry on this page is generated from the mendapi change database at build time and links its upstream source.