Attachment to Files Migration
This skill activates when a practitioner needs to convert legacy Classic Attachment (and Note) records to modern Salesforce Files (ContentDocument / ContentVersion / ContentDocumentLink), preserving parent linkage, owner, sharing, and an audit trail through a re-runnable, idempotent process.
Before Starting
Gather this context before working on anything in this domain:
- Inventory the volume:
SELECT COUNT(Id), SUM(BodyLength) FROM Attachment and the same on Note. Sub-100K and < 5GB total can be done with Batch Apex; larger requires Bulk API 2.0 + external orchestration to avoid heap pressure.
- Confirm "Notes" are Classic Notes (
Note sObject) vs Enhanced Notes (ContentNote, already a Files record). Only Classic Note records need migrating; Enhanced Notes are already Files.
- Inventory parent-object distribution:
SELECT ParentId.Type, COUNT(Id) FROM Attachment GROUP BY ParentId.Type. Some parents (Email, Task) have idiosyncratic Files behavior — Email attachments may already be linked via EmailMessage instead.
- Confirm the org has Files enabled and that
ContentDocumentLink has an OWD permitting users to receive shared files. If OWD is private and the migration runs as an integration user, all migrated files will be invisible to the original owners until ContentDocumentLink rows are created with the correct visibility.
Core Concepts
1. Object Model Mapping
| Classic Object |
Files Equivalent |
Notes |
Attachment |
ContentVersion (one row per version) + ContentDocument (parent envelope) + ContentDocumentLink (parent linkage and sharing) |
A new ContentDocument is implicitly created when you insert a ContentVersion with no ContentDocumentId |
Note (Classic) |
ContentNote (special ContentVersion subtype) |
Body is HTML in ContentNote; Classic Notes are plain text — escape & wrap in <p> tags |
Attachment.ParentId |
ContentDocumentLink.LinkedEntityId |
Links the file to the original parent record |
Attachment.OwnerId |
ContentVersion.OwnerId (and indirectly ContentDocument.OwnerId) |
Owner must exist and be active at insert time, otherwise the row fails |
Attachment.IsPrivate |
ContentDocumentLink.Visibility = 'InternalUsers' (vs AllUsers) |
Private attachments map to internal-user visibility, NOT to the same record-level private flag |
2. The Three-Object Insert Sequence
Every migrated attachment requires three inserts in the right order:
| Step |
sObject |
Required fields |
Purpose |
| 1 |
ContentVersion |
Title, PathOnClient, VersionData, OwnerId, FirstPublishLocationId (optional) |
Creates the file content; auto-creates a ContentDocument |
| 2 |
(auto) Query ContentDocumentId from inserted ContentVersion |
n/a |
Capture the parent envelope ID |
| 3 |
ContentDocumentLink |
ContentDocumentId, LinkedEntityId, ShareType, Visibility |
Links the file to the original parent record and sets sharing |
If you set FirstPublishLocationId on the ContentVersion, Salesforce auto-creates the ContentDocumentLink to that parent — but you still need explicit links for any additional parents and to control Visibility precisely.
3. Sharing Translation
Classic Attachment sharing was simple: inherits parent record sharing, plus an IsPrivate flag that hid it from everyone except the owner and admins. Files sharing is multi-dimensional:
| Dimension |
Values |
Migration default |
ContentDocumentLink.ShareType |
V (Viewer), C (Collaborator), I (Inferred from parent) |
V for migrated parent links |
ContentDocumentLink.Visibility |
AllUsers, InternalUsers, SharedUsers |
AllUsers if Attachment.IsPrivate=false; InternalUsers if private |
ContentDocument.SharingPrivacy |
N (None), P (Private on Record) |
P matches Attachment.IsPrivate=true |
ShareType controls what the linked user can do; Visibility controls who in the parent record's audience sees it. They are independent. Setting only one results in surprising access patterns.
4. Heap and DML Considerations at Volume
Attachment bodies live in Attachment.Body (a Blob). When you query SELECT Body FROM Attachment LIMIT N, every body is loaded into Apex heap (6 MB default). At an average 500 KB per attachment, you can hold ~10 attachments in heap simultaneously before risk. Migration jobs MUST stream — query a small batch, insert, release references, repeat — never bulk-load the full body set.
| Approach |
Records per chunk |
Where it fits |
Batch Apex with scope=10 |
10 |
Standard for ~100K attachments, average <2MB each |
Batch Apex with scope=1 |
1 |
Required when individual files exceed 5MB |
| Bulk API 2.0 from external |
configurable |
Required for >500K attachments or >10GB total |
| Queueable chain |
1–10 per execution |
Required when callouts to external storage are part of the chain |
Common Patterns
Pattern 1: Idempotent Batch with Origin Tracking
When to use: Most migrations. Volume 10K–500K Attachments, runs in-org without external dependencies.
How it works:
- Add a custom field
Source_Attachment_Id__c (External ID, Unique) to a custom object or to ContentVersion itself if your org allows custom fields on it (it does).
- Batch Apex scope = 10. Query
SELECT Id, ParentId, OwnerId, Name, Body, BodyLength, IsPrivate FROM Attachment WHERE Id NOT IN (SELECT Source_Attachment_Id__c FROM ContentVersion).
- For each row: build
ContentVersion, set Source_Attachment_Id__c = original Attachment.Id, insert.
- Re-query to get
ContentDocumentId from the inserted versions.
- Build
ContentDocumentLink rows for each parent; insert.
- Log per-Attachment outcome to a custom
Migration_Log__c object: success / failure / reason.
Why not the alternative: Without origin tracking, a re-run after a partial failure duplicates files. With it, the WHERE NOT IN clause in step 2 makes the job re-runnable safely.
Pattern 2: Notes (Classic) → ContentNote
When to use: The org used Classic Notes (Note sObject) and is enabling Enhanced Notes / Files-only.
How it works:
- Query
SELECT Id, Title, Body, ParentId, OwnerId FROM Note.
- For each, build a
ContentNote: Title = Note.Title, Content = Blob.valueOf('<p>' + Note.Body.escapeHtml4() + '</p>'). Replace newlines with <br/>.
- Insert the
ContentNote.
- Insert
ContentDocumentLink with LinkedEntityId = Note.ParentId, ShareType = 'V', Visibility = 'AllUsers'.
Why not the alternative: Inserting Notes as ContentVersion with Title.html extension does not produce a "Note" — it produces an HTML file. Users browsing the related list won't see it as a Note. ContentNote is the correct sObject.
Pattern 3: Phased Live Cutover
When to use: Org cannot tolerate downtime; migration must run in production while users continue creating Attachments.
How it works:
- Deploy a Trigger on
Attachment that, on after insert, copies the new Attachment to Files immediately (via Queueable to avoid synchronous DML on the same operation).
- Run the bulk historical migration in the background. Both the trigger and the batch use the
Source_Attachment_Id__c deduplication so a record migrated by the trigger isn't double-processed.
- Once the batch finishes, run a verification report:
SELECT COUNT(Id) FROM Attachment WHERE Id NOT IN (SELECT Source_Attachment_Id__c FROM ContentVersion). Expect zero.
- Disable Attachment creation org-wide via permission set removal of
Modify All Data-equivalent on Attachment, or via a validation rule that fails on insert with a "use Files" message.
- After a soak window, archive (export + delete) original Attachments.
Why not the alternative: A pure batch migration leaves a window where users are still creating Attachments. The trigger closes the window without waiting for the batch to finish.
Pattern 4: Selective Migration with Filter Predicate
When to use: Only a subset of Attachments are needed in Files (e.g., only those on Cases from the last 3 years).
How it works:
- Identify the predicate (e.g.,
WHERE Parent.CreatedDate > LAST_N_YEARS:3 AND Parent.Type = 'Case').
- Run the standard batch but constrain the
start() query to the predicate.
- Add a
Migration_Reason__c field on Migration_Log__c so excluded Attachments are logged with the reason.
- Decide cleanup separately: out-of-scope Attachments may be retained as-is, archived to external storage, or deleted after a separate sign-off.
Why not the alternative: Migrating everything "to be safe" inflates Files storage by orders of magnitude and may push the org over its allocation. Files storage is pricier per MB than Attachment storage; a deliberate predicate is cost discipline.
Decision Guidance
| Situation |
Recommended Approach |
Reason |
| <100K Attachments, <5GB total, low-traffic org |
Batch Apex with scope=10 |
Native, runnable in-org, idempotent if source-tracking is added |
| >500K Attachments OR >10GB |
Bulk API 2.0 from external script |
Avoids heap pressure and Apex CPU limits at scale |
| Production org with active Attachment creation |
Trigger + Batch combo (Pattern 3) |
Closes the live-data window |
| Need selective subset |
Predicate-driven batch (Pattern 4) |
Controls Files storage cost and migration scope |
Classic Notes (Note sObject) present |
Separate ContentNote job (Pattern 2) |
Notes need different sObject; do NOT mix into Attachment batch |
Email attachments (parent is EmailMessage) |
Verify if already linked via EmailMessageRelation |
Some email attachments are stored in Files already |
| Cleanup of source Attachments |
Separate post-verification batch with audit log |
Never delete in the same transaction as create — reconcile first |
Migration must preserve CreatedDate |
Set Audit Field Customization permission and write to CreatedDate on ContentVersion |
Default is "now"; explicit field write needs the special perm |
Recommended Workflow
Step-by-step instructions for an AI agent or practitioner working on this task:
- Profile the source. Run COUNT/SUM queries on
Attachment and Note, group by parent type and by size bucket (<1MB, 1–5MB, 5–25MB, >25MB). Confirm Files is enabled and check the org's Files storage allocation.
- Add
Source_Attachment_Id__c (External ID, Unique) to ContentVersion. This single field unlocks idempotent re-runs and reconciliation.
- Build the batch class. Use
scope=10 as default; lower it to scope=1 if any single file may exceed ~5MB. Implement start (query Attachments not yet migrated), execute (build ContentVersion + ContentDocumentLink rows, insert with Database.insert(rows, false) for partial-success), finish (chain to next batch or write summary log).
- Translate sharing. For each
Attachment.IsPrivate=true, set ContentDocumentLink.Visibility='InternalUsers'. For false, use 'AllUsers'. Choose ShareType='V' for parent-record links.
- Run on a sandbox copy first. Migrate a representative slice (10K records spanning all parent types). Verify file accessibility from each parent record's UI, owner correctness, and sharing visibility against expected users.
- Cutover in production. Deploy the Attachment-after-insert trigger. Start the batch. Monitor
Migration_Log__c for failures and re-run the batch to retry — the dedup key makes it safe.
- Verify and clean up. After zero-pending count is confirmed, run a separate cleanup batch that deletes original Attachments in chunks of 200, gated by a flag (
Cleanup_Approved__c). Keep the Migration_Log__c for audit.
Review Checklist
Run through these before marking work in this area complete:
Salesforce-Specific Gotchas
Non-obvious platform behaviors that cause real production problems:
ContentDocument is created implicitly by ContentVersion insert — there is no direct insert path. Inserting a ContentDocument record directly is not supported. The pattern is: insert ContentVersion (which auto-creates a ContentDocument parent), re-query the inserted ContentVersion to read its ContentDocumentId, then build the ContentDocumentLink. Trying to model the migration with a ContentDocument insert step fails with no clear error.
Attachment.Body query loads the full blob into heap. A query like SELECT Body FROM Attachment WHERE ... materializes every blob in the result set. With heap limited to 6 MB (12 MB async), even a small batch of large files OOMs the transaction. Always set a small scope and treat each row's body as a one-time stream — assign to ContentVersion.VersionData immediately and let it go out of scope.
Setting FirstPublishLocationId AND inserting an explicit ContentDocumentLink for the same parent creates two links. The convenience parameter FirstPublishLocationId on ContentVersion auto-creates the ContentDocumentLink. If your migration also inserts an explicit ContentDocumentLink to the same parent for control over ShareType / Visibility, you get two link rows. Pick one approach per parent and stick to it.
Visibility='AllUsers' is rejected if the parent object's OWD is private. Setting ContentDocumentLink.Visibility='AllUsers' on a link whose LinkedEntityId parent has private OWD throws a FIELD_INTEGRITY_EXCEPTION. Either change the OWD before migrating (rare) or downgrade visibility to InternalUsers for those parents and document the difference.
Owner of an inactive user fails silently in ContentDocumentLink insert. If Attachment.OwnerId points to a deactivated user, the ContentVersion insert with that OwnerId succeeds (Salesforce permits inactive owners on Files), but downstream logic that expects "owner can see file" breaks. Decide a fallback: (a) reassign ownership to a designated migration user, (b) preserve the inactive owner and accept the visibility consequence, or (c) skip and log. There is no platform default.
CreatedDate and CreatedById are not preserved without "Audit Field Customization" permission. ContentVersion.CreatedDate defaults to the migration timestamp; original Attachment.CreatedDate is lost unless the migration user has the "Set Audit Fields upon Record Creation" permission AND your code explicitly sets CreatedDate and CreatedById. Some migrations decide audit history isn't worth preserving; others must preserve it for compliance — confirm before starting.
ContentNote body is HTML — Classic Note body is plain text. Naively setting ContentNote.Content = Blob.valueOf(Note.Body) produces unrendered text instead of paragraphs. The body must be wrapped (<p>...</p>), HTML-escaped (Note.Body.escapeHtml4()), and have newlines converted (replaceAll('\\n', '<br/>')). Otherwise the migrated Note appears as a single line of run-on text.
Files sharing has both ShareType and Visibility — they are not synonyms. ShareType controls what the audience can do (View, Collaborate, Inferred). Visibility controls who in the parent's audience sees the file (AllUsers, InternalUsers, SharedUsers). Setting ShareType='V' with Visibility='SharedUsers' is meaningless — SharedUsers requires no automatic propagation, so no one will see it. Migrate with explicit choices on both axes.
Output Artifacts
| Artifact |
Description |
Batch Apex class (e.g., AttachmentToFilesMigration.cls) |
Idempotent migration job with origin tracking and partial-success logging |
Source_Attachment_Id__c field on ContentVersion |
External ID, Unique — enables re-runs and reconciliation |
Migration_Log__c custom object |
Per-Attachment outcome log: success, failure with reason, file size, parent type |
| Cleanup batch class |
Deletes source Attachments after a separate approval gate |
| Verification SOQL pack |
Reconciliation queries for confirming zero-pending and matching counts by parent type |
| Updated downstream references |
Reports, list views, LWC getRelatedListRecords calls switched from Attachment to ContentDocumentLink |
Related Skills
data/salesforce-files-architecture — Use when designing new file-storage architecture (post-migration), not the migration itself
integration/file-and-document-integration — Use when ingesting files from external systems into Salesforce Files
apex/batch-apex-patterns — Use when designing the batch class structure (scope, state, finish-chaining)
data/data-archival-strategies — Use when the migration plan includes archival of source Attachments to external storage
admin/sharing-and-visibility — Use when the OWD on parent objects must be reviewed before setting ContentDocumentLink.Visibility
1---2name: attachment-to-files-migration3description: Migrating Classic Notes & Attachments to Salesforce Files (ContentDocument / ContentVersion / ContentDocumentLink): bulk extraction, owner and parent preservation, sharing translation, idempotent re-runs, and post-migration cleanup. Triggers: 'attachments to files', 'notes and attachments migration', 'ContentDocument from Attachment', 'enable Files for Salesforce'. NOT for general file storage strategy (use data/salesforce-files-architecture) or for ContentVersion API patterns in new code (use integration/file-and-document-integration).4---56# Attachment to Files Migration78This skill activates when a practitioner needs to convert legacy Classic `Attachment` (and `Note`) records to modern Salesforce Files (`ContentDocument` / `ContentVersion` / `ContentDocumentLink`), preserving parent linkage, owner, sharing, and an audit trail through a re-runnable, idempotent process.910---1112## Before Starting1314Gather this context before working on anything in this domain:1516- Inventory the volume: `SELECT COUNT(Id), SUM(BodyLength) FROM Attachment` and the same on `Note`. Sub-100K and < 5GB total can be done with Batch Apex; larger requires Bulk API 2.0 + external orchestration to avoid heap pressure.17- Confirm "Notes" are Classic Notes (`Note` sObject) vs Enhanced Notes (`ContentNote`, already a Files record). Only Classic `Note` records need migrating; Enhanced Notes are already Files.18- Inventory parent-object distribution: `SELECT ParentId.Type, COUNT(Id) FROM Attachment GROUP BY ParentId.Type`. Some parents (Email, Task) have idiosyncratic Files behavior — Email attachments may already be linked via `EmailMessage` instead.19- Confirm the org has Files enabled and that `ContentDocumentLink` has an OWD permitting users to receive shared files. If OWD is private and the migration runs as an integration user, all migrated files will be invisible to the original owners until ContentDocumentLink rows are created with the correct visibility.2021---2223## Core Concepts2425### 1. Object Model Mapping2627| Classic Object | Files Equivalent | Notes |28|---|---|---|29| `Attachment` | `ContentVersion` (one row per version) + `ContentDocument` (parent envelope) + `ContentDocumentLink` (parent linkage and sharing) | A new `ContentDocument` is implicitly created when you insert a `ContentVersion` with no `ContentDocumentId` |30| `Note` (Classic) | `ContentNote` (special `ContentVersion` subtype) | Body is HTML in `ContentNote`; Classic Notes are plain text — escape & wrap in `<p>` tags |31| `Attachment.ParentId` | `ContentDocumentLink.LinkedEntityId` | Links the file to the original parent record |32| `Attachment.OwnerId` | `ContentVersion.OwnerId` (and indirectly `ContentDocument.OwnerId`) | Owner must exist and be active at insert time, otherwise the row fails |33| `Attachment.IsPrivate` | `ContentDocumentLink.Visibility = 'InternalUsers'` (vs `AllUsers`) | Private attachments map to internal-user visibility, NOT to the same record-level private flag |3435### 2. The Three-Object Insert Sequence3637Every migrated attachment requires three inserts in the right order:3839| Step | sObject | Required fields | Purpose |40|---|---|---|---|41| 1 | `ContentVersion` | `Title`, `PathOnClient`, `VersionData`, `OwnerId`, `FirstPublishLocationId` (optional) | Creates the file content; auto-creates a `ContentDocument` |42| 2 | (auto) Query `ContentDocumentId` from inserted `ContentVersion` | n/a | Capture the parent envelope ID |43| 3 | `ContentDocumentLink` | `ContentDocumentId`, `LinkedEntityId`, `ShareType`, `Visibility` | Links the file to the original parent record and sets sharing |4445If you set `FirstPublishLocationId` on the `ContentVersion`, Salesforce auto-creates the `ContentDocumentLink` to that parent — but you still need explicit links for any additional parents and to control `Visibility` precisely.4647### 3. Sharing Translation4849Classic Attachment sharing was simple: inherits parent record sharing, plus an `IsPrivate` flag that hid it from everyone except the owner and admins. Files sharing is multi-dimensional:5051| Dimension | Values | Migration default |52|---|---|---|53| `ContentDocumentLink.ShareType` | `V` (Viewer), `C` (Collaborator), `I` (Inferred from parent) | `V` for migrated parent links |54| `ContentDocumentLink.Visibility` | `AllUsers`, `InternalUsers`, `SharedUsers` | `AllUsers` if `Attachment.IsPrivate=false`; `InternalUsers` if private |55| `ContentDocument.SharingPrivacy` | `N` (None), `P` (Private on Record) | `P` matches `Attachment.IsPrivate=true` |5657`ShareType` controls what the linked user can do; `Visibility` controls who in the parent record's audience sees it. They are independent. Setting only one results in surprising access patterns.5859### 4. Heap and DML Considerations at Volume6061Attachment bodies live in `Attachment.Body` (a Blob). When you query `SELECT Body FROM Attachment LIMIT N`, every body is loaded into Apex heap (6 MB default). At an average 500 KB per attachment, you can hold ~10 attachments in heap simultaneously before risk. Migration jobs MUST stream — query a small batch, insert, release references, repeat — never bulk-load the full body set.6263| Approach | Records per chunk | Where it fits |64|---|---|---|65| Batch Apex with `scope=10` | 10 | Standard for ~100K attachments, average <2MB each |66| Batch Apex with `scope=1` | 1 | Required when individual files exceed 5MB |67| Bulk API 2.0 from external | configurable | Required for >500K attachments or >10GB total |68| Queueable chain | 1–10 per execution | Required when callouts to external storage are part of the chain |6970---7172## Common Patterns7374### Pattern 1: Idempotent Batch with Origin Tracking7576**When to use:** Most migrations. Volume 10K–500K Attachments, runs in-org without external dependencies.7778**How it works:**791. Add a custom field `Source_Attachment_Id__c` (External ID, Unique) to a custom object or to `ContentVersion` itself if your org allows custom fields on it (it does).802. Batch Apex scope = 10. Query `SELECT Id, ParentId, OwnerId, Name, Body, BodyLength, IsPrivate FROM Attachment WHERE Id NOT IN (SELECT Source_Attachment_Id__c FROM ContentVersion)`.813. For each row: build `ContentVersion`, set `Source_Attachment_Id__c` = original `Attachment.Id`, insert.824. Re-query to get `ContentDocumentId` from the inserted versions.835. Build `ContentDocumentLink` rows for each parent; insert.846. Log per-Attachment outcome to a custom `Migration_Log__c` object: success / failure / reason.8586**Why not the alternative:** Without origin tracking, a re-run after a partial failure duplicates files. With it, the `WHERE NOT IN` clause in step 2 makes the job re-runnable safely.8788### Pattern 2: Notes (Classic) → ContentNote8990**When to use:** The org used Classic Notes (`Note` sObject) and is enabling Enhanced Notes / Files-only.9192**How it works:**931. Query `SELECT Id, Title, Body, ParentId, OwnerId FROM Note`.942. For each, build a `ContentNote`: `Title = Note.Title`, `Content = Blob.valueOf('<p>' + Note.Body.escapeHtml4() + '</p>')`. Replace newlines with `<br/>`.953. Insert the `ContentNote`.964. Insert `ContentDocumentLink` with `LinkedEntityId = Note.ParentId`, `ShareType = 'V'`, `Visibility = 'AllUsers'`.9798**Why not the alternative:** Inserting Notes as `ContentVersion` with `Title.html` extension does not produce a "Note" — it produces an HTML file. Users browsing the related list won't see it as a Note. `ContentNote` is the correct sObject.99100### Pattern 3: Phased Live Cutover101102**When to use:** Org cannot tolerate downtime; migration must run in production while users continue creating Attachments.103104**How it works:**1051. Deploy a Trigger on `Attachment` that, on `after insert`, copies the new Attachment to Files immediately (via Queueable to avoid synchronous DML on the same operation).1062. Run the bulk historical migration in the background. Both the trigger and the batch use the `Source_Attachment_Id__c` deduplication so a record migrated by the trigger isn't double-processed.1073. Once the batch finishes, run a verification report: `SELECT COUNT(Id) FROM Attachment WHERE Id NOT IN (SELECT Source_Attachment_Id__c FROM ContentVersion)`. Expect zero.1084. Disable Attachment creation org-wide via permission set removal of `Modify All Data`-equivalent on Attachment, or via a validation rule that fails on insert with a "use Files" message.1095. After a soak window, archive (export + delete) original Attachments.110111**Why not the alternative:** A pure batch migration leaves a window where users are still creating Attachments. The trigger closes the window without waiting for the batch to finish.112113### Pattern 4: Selective Migration with Filter Predicate114115**When to use:** Only a subset of Attachments are needed in Files (e.g., only those on Cases from the last 3 years).116117**How it works:**1181. Identify the predicate (e.g., `WHERE Parent.CreatedDate > LAST_N_YEARS:3 AND Parent.Type = 'Case'`).1192. Run the standard batch but constrain the `start()` query to the predicate.1203. Add a `Migration_Reason__c` field on `Migration_Log__c` so excluded Attachments are logged with the reason.1214. Decide cleanup separately: out-of-scope Attachments may be retained as-is, archived to external storage, or deleted after a separate sign-off.122123**Why not the alternative:** Migrating everything "to be safe" inflates Files storage by orders of magnitude and may push the org over its allocation. Files storage is pricier per MB than Attachment storage; a deliberate predicate is cost discipline.124125---126127## Decision Guidance128129| Situation | Recommended Approach | Reason |130|---|---|---|131| <100K Attachments, <5GB total, low-traffic org | Batch Apex with `scope=10` | Native, runnable in-org, idempotent if source-tracking is added |132| >500K Attachments OR >10GB | Bulk API 2.0 from external script | Avoids heap pressure and Apex CPU limits at scale |133| Production org with active Attachment creation | Trigger + Batch combo (Pattern 3) | Closes the live-data window |134| Need selective subset | Predicate-driven batch (Pattern 4) | Controls Files storage cost and migration scope |135| Classic Notes (`Note` sObject) present | Separate `ContentNote` job (Pattern 2) | Notes need different sObject; do NOT mix into Attachment batch |136| Email attachments (parent is `EmailMessage`) | Verify if already linked via `EmailMessageRelation` | Some email attachments are stored in Files already |137| Cleanup of source Attachments | Separate post-verification batch with audit log | Never delete in the same transaction as create — reconcile first |138| Migration must preserve `CreatedDate` | Set `Audit Field Customization` permission and write to `CreatedDate` on `ContentVersion` | Default is "now"; explicit field write needs the special perm |139140---141142## Recommended Workflow143144Step-by-step instructions for an AI agent or practitioner working on this task:1451461. **Profile the source.** Run COUNT/SUM queries on `Attachment` and `Note`, group by parent type and by size bucket (<1MB, 1–5MB, 5–25MB, >25MB). Confirm Files is enabled and check the org's Files storage allocation.1472. **Add `Source_Attachment_Id__c` (External ID, Unique) to `ContentVersion`.** This single field unlocks idempotent re-runs and reconciliation.1483. **Build the batch class.** Use `scope=10` as default; lower it to `scope=1` if any single file may exceed ~5MB. Implement `start` (query Attachments not yet migrated), `execute` (build ContentVersion + ContentDocumentLink rows, insert with `Database.insert(rows, false)` for partial-success), `finish` (chain to next batch or write summary log).1494. **Translate sharing.** For each `Attachment.IsPrivate=true`, set `ContentDocumentLink.Visibility='InternalUsers'`. For `false`, use `'AllUsers'`. Choose `ShareType='V'` for parent-record links.1505. **Run on a sandbox copy first.** Migrate a representative slice (10K records spanning all parent types). Verify file accessibility from each parent record's UI, owner correctness, and sharing visibility against expected users.1516. **Cutover in production.** Deploy the Attachment-after-insert trigger. Start the batch. Monitor `Migration_Log__c` for failures and re-run the batch to retry — the dedup key makes it safe.1527. **Verify and clean up.** After zero-pending count is confirmed, run a separate cleanup batch that deletes original Attachments in chunks of 200, gated by a flag (`Cleanup_Approved__c`). Keep the `Migration_Log__c` for audit.153154---155156## Review Checklist157158Run through these before marking work in this area complete:159160- [ ] `Source_Attachment_Id__c` (External ID, Unique) is on `ContentVersion` and populated for every migrated row161- [ ] Batch `scope` accounts for the largest individual file size — no heap-exceeded errors in the test run162- [ ] `Database.insert(rows, false)` is used for partial-success; failures are logged with reason, not lost163- [ ] `ContentDocumentLink.Visibility` correctly maps `Attachment.IsPrivate` (`InternalUsers` vs `AllUsers`)164- [ ] Owner (`OwnerId`) matches the original Attachment owner; orphaned-owner cases (inactive user) are handled with a documented fallback165- [ ] Migration_Log__c rows exist for every Attachment with success/failure outcome166- [ ] Verification count: `SELECT COUNT(Id) FROM Attachment WHERE Id NOT IN (SELECT Source_Attachment_Id__c FROM ContentVersion)` = 0 (or matches the documented exclusion predicate)167- [ ] Cleanup of source Attachments is GATED on a separate approval flag — not auto-deleted in the same transaction168- [ ] Reports, list views, and LWC components that referenced `Attachments` relationships have been updated to `ContentDocumentLinks` or `AttachedContentDocuments`169- [ ] If the org used Classic Notes, a separate ContentNote migration job is included in the cutover plan170171---172173## Salesforce-Specific Gotchas174175Non-obvious platform behaviors that cause real production problems:1761771. **`ContentDocument` is created implicitly by `ContentVersion` insert — there is no direct insert path.** Inserting a `ContentDocument` record directly is not supported. The pattern is: insert `ContentVersion` (which auto-creates a `ContentDocument` parent), re-query the inserted `ContentVersion` to read its `ContentDocumentId`, then build the `ContentDocumentLink`. Trying to model the migration with a `ContentDocument` insert step fails with no clear error.1781792. **`Attachment.Body` query loads the full blob into heap.** A query like `SELECT Body FROM Attachment WHERE ...` materializes every blob in the result set. With heap limited to 6 MB (12 MB async), even a small batch of large files OOMs the transaction. Always set a small `scope` and treat each row's body as a one-time stream — assign to `ContentVersion.VersionData` immediately and let it go out of scope.1801813. **Setting `FirstPublishLocationId` AND inserting an explicit `ContentDocumentLink` for the same parent creates two links.** The convenience parameter `FirstPublishLocationId` on `ContentVersion` auto-creates the `ContentDocumentLink`. If your migration also inserts an explicit `ContentDocumentLink` to the same parent for control over `ShareType` / `Visibility`, you get two link rows. Pick one approach per parent and stick to it.1821834. **`Visibility='AllUsers'` is rejected if the parent object's OWD is private.** Setting `ContentDocumentLink.Visibility='AllUsers'` on a link whose `LinkedEntityId` parent has private OWD throws a `FIELD_INTEGRITY_EXCEPTION`. Either change the OWD before migrating (rare) or downgrade visibility to `InternalUsers` for those parents and document the difference.1841855. **Owner of an inactive user fails silently in `ContentDocumentLink` insert.** If `Attachment.OwnerId` points to a deactivated user, the `ContentVersion` insert with that `OwnerId` succeeds (Salesforce permits inactive owners on Files), but downstream logic that expects "owner can see file" breaks. Decide a fallback: (a) reassign ownership to a designated migration user, (b) preserve the inactive owner and accept the visibility consequence, or (c) skip and log. There is no platform default.1861876. **`CreatedDate` and `CreatedById` are not preserved without "Audit Field Customization" permission.** `ContentVersion.CreatedDate` defaults to the migration timestamp; original `Attachment.CreatedDate` is lost unless the migration user has the "Set Audit Fields upon Record Creation" permission AND your code explicitly sets `CreatedDate` and `CreatedById`. Some migrations decide audit history isn't worth preserving; others must preserve it for compliance — confirm before starting.1881897. **ContentNote body is HTML — Classic Note body is plain text.** Naively setting `ContentNote.Content = Blob.valueOf(Note.Body)` produces unrendered text instead of paragraphs. The body must be wrapped (`<p>...</p>`), HTML-escaped (`Note.Body.escapeHtml4()`), and have newlines converted (`replaceAll('\\n', '<br/>')`). Otherwise the migrated Note appears as a single line of run-on text.1901918. **Files sharing has both `ShareType` and `Visibility` — they are not synonyms.** `ShareType` controls what the audience can do (`V`iew, `C`ollaborate, `I`nferred). `Visibility` controls who in the parent's audience sees the file (`AllUsers`, `InternalUsers`, `SharedUsers`). Setting `ShareType='V'` with `Visibility='SharedUsers'` is meaningless — `SharedUsers` requires no automatic propagation, so no one will see it. Migrate with explicit choices on both axes.192193---194195## Output Artifacts196197| Artifact | Description |198|---|---|199| Batch Apex class (e.g., `AttachmentToFilesMigration.cls`) | Idempotent migration job with origin tracking and partial-success logging |200| `Source_Attachment_Id__c` field on `ContentVersion` | External ID, Unique — enables re-runs and reconciliation |201| `Migration_Log__c` custom object | Per-Attachment outcome log: success, failure with reason, file size, parent type |202| Cleanup batch class | Deletes source Attachments after a separate approval gate |203| Verification SOQL pack | Reconciliation queries for confirming zero-pending and matching counts by parent type |204| Updated downstream references | Reports, list views, LWC `getRelatedListRecords` calls switched from Attachment to ContentDocumentLink |205206---207208## Related Skills209210- `data/salesforce-files-architecture` — Use when designing new file-storage architecture (post-migration), not the migration itself211- `integration/file-and-document-integration` — Use when ingesting files from external systems into Salesforce Files212- `apex/batch-apex-patterns` — Use when designing the batch class structure (scope, state, finish-chaining)213- `data/data-archival-strategies` — Use when the migration plan includes archival of source Attachments to external storage214- `admin/sharing-and-visibility` — Use when the OWD on parent objects must be reviewed before setting `ContentDocumentLink.Visibility`