Moving Work From Sandbox to Production Without an SDF Round Trip
The script works in sandbox. You watched it run. Now it has to exist in production, and what you are actually moving is not the script.
You are moving a file, a script record that points at that file, a deployment that points at that record, two custom fields the script writes to, a custom list one of those fields draws from, and a saved search the script loads by ID. Six objects with four dependency edges between them, and if you create them in the wrong order at least two of them fail.
This is why the ten-minute change takes an afternoon.
What SDF is good at, and where it stops
The SuiteCloud Development Framework is the right tool for code that lives in a repository. You keep a project, you validate against the target account, you deploy, and the objects arrive in the correct order because SDF understands the dependencies between them. If your team works that way, none of what follows should replace it.
The problem is what SDF knows about. An SDF project contains the objects you put in it. The custom field somebody added through the UI last Tuesday is not in your project until you import it. Neither is the saved search the accountant built, or the custom record type that was created in a hurry during a support call.
So the actual production workflow for a change that started in the browser is: import the objects into a local project, resolve whatever the import brings along that you did not expect, validate against production, fix the failures, deploy. Every one of those steps is correct. Together they are longer than the change, and the length is why people do not do them at 4pm on a Friday.
There is a second path that everybody uses and nobody documents, which is to recreate the objects by hand in production while reading the sandbox screen next to it. That path has no validation, no record of what was moved, and no way to tell afterwards whether you finished.
The rule that breaks every hand migration
Internal IDs do not survive the trip. Script IDs do.
A custom field in sandbox might be internal ID 847. The same field, created in production, will be some other number. Same for saved searches, custom record types, script records, deployments and every custom list value. The internal ID is assigned by the account, and the two accounts have no reason to agree.
The string identifiers are the stable ones: custbody_approval_status, customsearch_open_orders, customscript_ue_welcome, customdeploy_ue_welcome. Those are yours, you chose them, and they are the same in both accounts if you set them the same in both accounts.
Which produces the single rule that everything else in this article follows from: anything that crosses the account boundary must be keyed on script ID, never on internal ID.
The place this bites hardest is inside the scripts themselves. A line like this works fine in sandbox forever:
const results = search.load({ id: 1247 }).run().getRange({ start: 0, end: 100 });
In production, 1247 is a different saved search, or nothing at all. If it is nothing you get an error and you fix it in ten minutes. If it is a different saved search you get results, and nobody notices until the numbers are wrong.
Search your codebase for numeric literals passed to search.load, record.load, runtime.getCurrentScript().getParameter defaults and anything reading a custom list value by ID. That grep is worth running before you move anything, and it is worth running against code that was moved years ago.
What "one step" actually means
There is no button that moves a customisation between accounts. What there is, and what you can build in an afternoon, is a package plus a plan.
The package is a folder. The plan is a file in it.
migrations/EA-14827/
migration-plan.md
scripts/
UE.WelcomeEmail.js
SL.DashboardApi.js
definitions/
custom-record-types.json
custom-fields.json
custom-lists.json
saved-searches.json
deployments.json
records.json
backups/
Each definition file is an array of plain objects keyed on script ID, holding exactly the fields needed to recreate the object. A deployment entry is small:
{
"scriptId": "customscript_ue_welcome",
"scriptType": "userevent",
"fileName": "UE.WelcomeEmail.js",
"deploymentId": "customdeploy_ue_welcome",
"status": "RELEASED",
"allRoles": true,
"recordType": "customer",
"title": "Welcome Email"
}
Nothing in that object is an internal ID. That is the whole point of it.
The plan is a checklist with an execution order, and the order is not arbitrary:
- Create the folder under
/SuiteScripts/ - Upload the script files
- Create the script records
- Create the deployments
- Create custom record types
- Create custom fields
- Create custom lists
- Create saved searches
- Create data records
Files before script records, because a script record needs a file to point at. Script records before deployments, for the same reason one level up. Record types before their fields. Lists before the fields that reference them. Searches after the fields they filter on, because a search that references a field that does not exist yet will not save.
That ordering is doing the job SDF's dependency resolution does, by hand, badly, and well enough. It handles the natural dependencies. It does not handle a custom field whose default value formula references another custom field created later in the same batch, and you will find that out when it fails.
Conflicts, and why both easy answers are wrong
Before creating anything, check whether it is already there:
SELECT id FROM customscript WHERE scriptid = 'customscript_ue_welcome'
If it comes back empty, create. If it comes back with a row, you have a decision, and the two tempting ways to automate that decision are both bad.
Auto-skip is bad because the most common reason an object already exists is that a previous version of it was deployed months ago and you are shipping a change to it. Skipping means your migration reports success and changes nothing.
Auto-update is bad because the second most common reason is that somebody edited it in production, on purpose, for a reason you do not know. Overwriting means the migration reports success and silently destroys somebody's work.
The only honest behaviour is to stop and ask, once per conflict, and to write the current version into backups/ before touching it. That is more clicks. It is also the difference between a migration you can explain afterwards and one you cannot.
Half-finished is the state that matters
Migrations get interrupted. A session expires, a governance limit hits, a browser tab closes, somebody has to go into a meeting. The state you land in is the dangerous one: some objects exist in production, some do not, and nobody can tell which from looking at production.
This is why the plan is a file with checkboxes rather than a script that runs top to bottom. Tick each item as it completes and append a timestamped line to a progress log in the same file:
2026-03-31T15:00:00Z - Created script record customscript_ue_welcome (ID 1234)
2026-03-31T15:00:05Z - CONFLICT customdeploy_ue_welcome exists. Chose UPDATE. Backup saved.
Resuming then means reading the file, skipping everything already ticked, and continuing from the first unchecked line. The file is also the only artefact anyone will have in six months when they ask what was deployed on that date and why.
Keep the whole package somewhere both accounts can see, because the two halves of the operation happen in two different environments and passing a folder between them by hand is exactly the step that gets skipped.
Workflows are the exception, and you should plan around them
Everything above assumes the object can be created programmatically. Workflows cannot. SuiteScript has no workflow creation API. Short of SDF, the only way a workflow gets into production is that a person builds it in the UI.
What you can do is extract the definition so the person rebuilding it is reading a specification rather than flipping between two browser windows. The workflow tables are queryable, so the states, transitions and actions can be pulled into a document:
SELECT id, name, scriptid, recordtype, releasestatus, initoncreate,
initeventtype, inittriggertype
FROM workflow WHERE scriptid = 'customworkflow_so_approval'
SELECT id, name, stateorder FROM workflowstate
WHERE workflow = 42 ORDER BY stateorder
SELECT id, state, targetstate, triggertypetext, conditionformula
FROM workflowtransition WHERE workflow = 42
Check the Records Catalog in your own account before relying on these, and expect the transition and action queries to be slow on a workflow with a lot of states. The internal IDs those queries return are for mapping states to each other inside the extract. Do not carry them across the boundary.
The output is a numbered checklist: create the workflow with this record type and script ID, create these states in this order, then these transitions with these triggers and conditions, then these actions per state. Rebuilding from that takes a fraction of the time that rebuilding from memory does, and it is verifiable afterwards.
Where this stops working
An honest list, because this pattern has real edges.
Backups are not rollback. Saving the previous version of an object into a folder means you can restore it by hand. It does not mean anything undoes itself. A migration that fails halfway leaves production in a mixed state and a human has to decide what to do about it.
A manifest only knows what it was told. If the packaging step records the objects from one piece of work, it will miss the custom field someone added to the same form last week. Nothing in this approach discovers dependencies it was not given.
It does not cover bundles, roles, preferences or integrations. Anything that is account configuration rather than a customisation object is out of scope, and some of it is deliberately not portable.
Script parameter values are account-specific by design. The parameter definitions move. The values you set on the deployment in sandbox are usually the wrong values for production, and copying them blindly is how a test email address ends up in a production notification.
It is not a replacement for SDF and git. If your code lives in a repository, keep it there. This is for the work that happens outside that pipeline, which in most accounts is more work than anyone admits.
The part worth keeping
Strip out the tooling and there are three ideas here, and all three are yours whether you use anything of ours or not.
Key everything on script IDs, because internal IDs are account-local and treating them as portable is the single most common cause of a migration that appears to work.
Write the plan down as a file that lives with the package, because half-finished is the normal outcome and the plan is the only thing that makes half-finished recoverable.
Stop on every conflict, because the two automatic answers are wrong in opposite directions and neither of them tells you which one you just got.
We built this into MokuBot as a migration mode, so the packaging and the replay happen in the two accounts without exporting anything to a laptop in between. The mechanism is the part that matters though, and none of it is proprietary. It is a folder, a JSON file per object type, and an ordered checklist that gets ticked as it goes.
The reason to build something like it is not that SDF is bad. It is that the gap between the process a team documented and the process a team uses at 4pm on a Friday is where production breaks, and that gap has been unserved for long enough that everyone has quietly agreed to pretend it is not there.