If you've written more than a few lines of SuiteScript, you've already experienced the moment: you deploy a script, trigger it, and nothing happens. Or worse, something happens, but not what you expected, and you have no idea why. NetSuite doesn't hand you a stack trace in the browser console. The feedback loop feels slow and opaque compared to typical web development environments.

The good news is that NetSuite gives you a solid set of debugging tools once you know where to look and how to use them. This guide walks through everything, from the humble log.debug call all the way to the Script Debugger, and gives you a systematic approach to diagnosing problems when things go wrong.

The Debugging Mindset

Before diving into tools, it's worth establishing a mindset. Debugging in SuiteScript is largely about making invisible things visible. NetSuite's execution engine runs your code on the server, asynchronously from your browser session. You can't just open DevTools and set a breakpoint. You need to instrument your code to tell you what it's doing.

This means thinking about three questions before you even look at an error:

  • Did the script run at all? Deployment issues, context guards, and governance failures can all prevent execution.
  • Did it run the code path you expected? Logic branches, guards, and early returns can quietly redirect your script's flow.
  • Did the data look the way you assumed? Wrong field IDs, unexpected value types, and missing records are common culprits.

Most bugs fall into one of these categories. Keeping this framework in mind will help you narrow down problems faster than staring at code and hoping the issue jumps out.

Layer 1: The N/log Module

The N/log module is your first line of defense and the tool you'll use most often throughout your SuiteScript career. It writes messages to the script's execution log, which you can view in the NetSuite UI.

The Four Log Levels

log.debug({ title: 'My title', details: 'Some value' });
log.audit({ title: 'My title', details: 'Some value' });
log.error({ title: 'My title', details: 'Some value' });
log.emergency({ title: 'My title', details: 'Some value' });

Each level serves a different purpose and has different default visibility:

  • debug is for development-time tracing and variable inspection. It only appears in logs when the deployment's log level is set to DEBUG.
  • audit is for key business events you always want recorded. Shown by default.
  • error is for caught exceptions and unexpected conditions. Shown by default.
  • emergency is for critical failures requiring immediate attention. Shown by default.

The most important practical implication: log.debug calls are suppressed in production unless the script's log level is explicitly set to DEBUG. This is a feature, not a bug. It means you can leave your debug statements in your code without polluting production logs. But it also means that if you deploy to production and wonder why you're not seeing your debug output, this is why.

You control the log level on the Script Deployment record in NetSuite.

Logging Best Practices

Always log at entry points. The first thing your entry point function should do is log that it was called, along with any key inputs. This answers question number one: did the script run at all?

const beforeSubmit = (context) => {
    log.debug({
        title: 'beforeSubmit — entry',
        details: `type: ${context.type} | record: ${context.newRecord.type} | id: ${context.newRecord.id}`
    });

    try {
        // ... your logic
    } catch (e) {
        log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
    }
};

Log the shape of data, not just its existence. Instead of logging "Got item", log the actual value. You'll often discover that a field is returning null, '', or an unexpected type.

// Less useful
log.debug({ title: 'item', details: 'got item value' });

// More useful
log.debug({ title: 'item value', details: itemId });
log.debug({ title: 'item type', details: typeof itemId });

Serialize objects with JSON.stringify. If you try to log a plain object or array, NetSuite will log [object Object], which tells you nothing. Always serialize complex values:

const lineData = {
    item: itemId,
    qty: quantity,
    rate: rate
};
log.debug({ title: 'Line data', details: JSON.stringify(lineData) });

Log before and after conditional branches. If your code has if/else logic or early returns, log which path was taken. This answers question number two: did it run the code path you expected?

if (context.type !== context.UserEventType.CREATE) {
    log.debug({ title: 'Skipping', details: `context.type is ${context.type}, not CREATE` });
    return;
}
log.debug({ title: 'Proceeding', details: 'context.type is CREATE' });

Log errors with full detail. A caught error has three useful properties. Always capture all three:

} catch (e) {
    log.error({
        title: e.name,
        details: `${e.message}\n${e.stack}`
    });
}

The e.stack is particularly valuable because it shows you the exact line number in your script where the error was thrown, not just the error message.

Using title and details Strategically

The title field is searchable and filterable in the execution log. Use it to make logs easy to scan:

  • Use consistent, structured titles: 'beforeSubmit — line loop', 'validateField — entry'
  • Put the variable value in details, not in title. Titles that vary per execution make logs hard to read.
  • For loops, include the index in the title: `Line ${i} — item value`

Layer 2: Reading Execution Logs

Once your script runs and generates log output, you need to know where to find it.

Script Deployment Execution Logs

Navigate to Customization > Scripting > Scripts, find your script, and click on it. From the script record, click on the Deployments subtab, then open the specific deployment. You'll see an Execution Log subtab that shows all log entries from recent executions.

Alternatively, navigate directly to Customization > Scripting > Script Execution Logs for a consolidated view across all scripts.

Each log entry shows the date and time of the execution, the log level (DEBUG, AUDIT, ERROR, EMERGENCY), the title and details from your log.* call, the script and deployment that generated the entry, the user who triggered the execution, and the duration of the execution.

Filtering and Reading Logs Effectively

The execution log can get noisy fast. Here are some strategies to find what you're looking for.

Filter by log level. If you're chasing an error, filter to ERROR only. If you're tracing logic flow, set it to DEBUG. Remember that debug logs only appear if the deployment's log level is DEBUG.

Filter by date range. Narrow to the time window when you triggered the issue. If you know you ran the script at 2:15 PM, filter to a 5-minute window around that time.

Use consistent title prefixes. If all your log calls for a specific feature start with 'Order Routing —', you can scan the title column quickly to find relevant entries.

Read in chronological order. Logs are often shown newest-first. When tracing a flow, reverse the sort or read from the bottom up.

Adjusting the Log Level on a Deployment

If you're not seeing your log.debug output, the deployment's log level is probably set to AUDIT or higher. To change it:

  1. Go to Customization > Scripting > Scripts
  2. Open your script, go to the Deployments subtab
  3. Open the deployment record
  4. Find the Log Level field and set it to DEBUG
  5. Save the deployment

One important note: set it back to AUDIT or ERROR before going to production. Running at DEBUG level in production generates a huge volume of log data, degrades performance, and fills up the execution log quickly.

Layer 3: The Script Debugger

For complex bugs that logging alone can't diagnose, NetSuite provides an interactive debugger. This is the closest thing to a traditional IDE debugging experience you'll get in SuiteScript. You can set breakpoints, step through code, and inspect variable values in real time.

When to Reach for the Debugger

The debugger is most valuable when:

  • You have a complex logical flow with many branches and logging would require dozens of statements
  • You're getting an error you can't reproduce predictably and need to inspect state at the moment of failure
  • You're debugging a script someone else wrote with minimal logging
  • You need to inspect the structure of an object or return value you're not familiar with

For simple bugs, like a wrong field ID, an unexpected null, or a missing commitLine, logging is faster. Reserve the debugger for problems that genuinely require interactive inspection.

Accessing the Debugger

Navigate to Customization > Scripting > Script Debugger. You'll see a code editor where you can paste your script and an execution panel where you control the debug session.

The Script Debugger works best for Suitelets and RESTlets because you can trigger them directly. For User Event scripts, you'll need a different approach (covered below).

Setting Breakpoints and Stepping Through Code

In the Script Debugger:

  1. Paste your script code into the editor panel
  2. Click in the gutter (left margin) next to a line number to set a breakpoint. A red dot will appear.
  3. In the execution panel, configure any input parameters your script needs
  4. Click Run to start execution

When execution hits your breakpoint, it pauses. You'll see the current line highlighted in the editor, a variables panel showing all in-scope variables and their current values, and a call stack showing how execution arrived at this point.

From a paused state, you can:

  • Step Over (F10) to execute the current line and pause at the next one
  • Step Into (F11) to enter a function call on the current line
  • Step Out (Shift+F11) to finish the current function and pause at the caller
  • Continue (F8) to run until the next breakpoint or the script ends

Inspecting Variables

The Variables panel is where the debugger earns its keep. You can expand objects and arrays to see their full structure. This is particularly useful for inspecting what context contains at a given point in a User Event script, seeing the full structure of a search result, and verifying the actual value of a field versus what you assumed it would be.

You can also evaluate expressions in the Console panel while paused. Type any JavaScript expression and it'll be evaluated in the current scope. For example, type rec.getValue({ fieldId: 'status' }) to check a field value without adding a log statement.

Debugging User Event Scripts

User Event scripts are triggered by record saves, not by direct URL calls, which makes them harder to debug interactively. Here are your main options.

Option 1: Logging. For most User Event bugs, thorough logging is the right approach. Set the deployment to DEBUG, reproduce the issue, then read the logs. In practice, this handles the vast majority of cases.

Option 2: Convert to a Suitelet for debugging. Extract the logic you're trying to debug into a Suitelet, trigger it from the debugger, verify the behavior, then move the logic back. It's more work but gives you full interactive debugging.

Option 3: Use a try/catch with maximum detail. Add very granular try/catch blocks that log the exact state at the moment of failure:

const beforeSubmit = (context) => {
    try {
        const rec = context.newRecord;

        let itemId, quantity;

        try {
            itemId = rec.getSublistValue({ sublistId: 'item', fieldId: 'item', line: 0 });
            log.debug({ title: 'itemId', details: JSON.stringify(itemId) });
        } catch (innerErr) {
            log.error({ title: 'Failed getting itemId', details: `${innerErr.message}\n${innerErr.stack}` });
            throw innerErr;
        }

        try {
            quantity = rec.getSublistValue({ sublistId: 'item', fieldId: 'quantity', line: 0 });
            log.debug({ title: 'quantity', details: JSON.stringify(quantity) });
        } catch (innerErr) {
            log.error({ title: 'Failed getting quantity', details: `${innerErr.message}\n${innerErr.stack}` });
            throw innerErr;
        }

        // ... rest of logic

    } catch (e) {
        log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
    }
};

This is verbose, but it will tell you exactly which operation threw, and what state the record was in at that moment.

Layer 4: Script Records and Deployment Status

Sometimes a script fails before it even runs your code. Understanding the script and deployment records helps you catch these infrastructure-level problems.

Script Records

Navigate to Customization > Scripting > Scripts. Each script record shows the script file (which file in the File Cabinet is being executed), the script type (User Event, Suitelet, Scheduled, etc.), the script ID (the unique identifier used in URLs and API calls), and the status (whether the script is active).

A common beginner mistake is editing the script file in the File Cabinet but forgetting that the script record still points to a cached version. After uploading a new version of your script file, verify that the script record is referencing the updated file. In some cases you may need to re-select the file to bust the cache.

Deployment Records

Each script can have multiple deployments. For example, a User Event script might be deployed to Sales Orders with different settings than its deployment to Purchase Orders.

Key fields on the deployment record to check when debugging:

  • Status must be Released for the script to run. A status of Testing means the script only runs for administrators. If your script seems to work for you but not for other users, check this first.
  • Execute As Role controls whose permissions the script runs with. If this is set, the script runs with the permissions of that role, not the current user. This can cause SSS_AUTHORIZATION_ERROR errors that are confusing if you don't know this field exists.
  • Log Level must be set to DEBUG to see log.debug output.
  • Deployment ID is useful for filtering logs when a script has multiple deployments.
  • Event Type / Filter controls which record operations trigger the script (CREATE, EDIT, DELETE, etc.). If your script isn't firing on edits but only on creates, check this field.

Layer 5: Common Error Messages and What They Actually Mean

NetSuite's error messages are sometimes helpful and sometimes cryptic. Here's a translation guide for the ones new developers hit most often.

SSS_MISSING_REQD_ARGUMENT

This means you called a SuiteScript method without a required parameter, or passed undefined where a value was expected. Common causes include calling rec.getValue({ fieldId: myVar }) where myVar is undefined because the variable was never assigned, forgetting to pass a required option to a module method, or a typo in a parameter name (fieldid instead of fieldId).

To debug this, log the variable you're passing in just before the failing call. Check the SuiteScript API docs for the correct parameter names and required fields.

SSS_INVALID_SUBLIST_OPERATION

This means you're trying to perform a sublist operation that isn't valid in the current mode, or on the wrong sublist. Common causes include calling selectNewLine or removeLine on a record loaded without isDynamic: true, calling setSublistValue with an incorrect sublistId, or trying to access a sublist that doesn't exist on this record type.

To debug this, first verify isDynamic: true is set on your record.load() call. Then verify the sublistId value in the Records Browser.

SSS_AUTHORIZATION_ERROR

This means the script is trying to access or modify something the current user (or the "Execute As" role) doesn't have permission to. Common causes include accessing a record type the current user's role can't see, reading a field that's restricted by field-level permissions, or the deployment's Execute As Role lacking necessary permissions.

To debug this, check the deployment's Execute As Role field. If it's not set, check the permissions of the user triggering the script. Try running as an Administrator to confirm the script logic works and permissions are the only issue.

UNEXPECTED_ERROR

This is the most frustrating one because it's the least descriptive. Something went wrong that NetSuite didn't classify into a more specific error type. Common causes include invalid data being passed to a save operation (a field value that violates a validation rule), a corrupt or incomplete sublist state (such as required sublist fields missing), and occasional platform-level issues.

To debug this, wrap each major operation in its own try/catch block to isolate exactly which line is throwing. Log everything: the full record state, every field you've set, every line you've modified. The goal is to narrow down which specific operation or data value is triggering the generic error.

SSS_TIME_LIMIT_EXCEEDED / SSS_GOVERNANCE_LIMIT_EXCEEDED

This means your script hit the execution time limit or governance unit limit for its script type. Common causes include a loop that processes far more records than expected, an inefficient search that returns thousands of results, or nested loops that multiply governance costs.

To debug this, add governance logging using runtime.getCurrentScript().getRemainingUsage():

/**
 * @NApiVersion 2.1
 * @NScriptType ScheduledScript
 * @NModuleScope SameAccount
 */
define(['N/runtime', 'N/log'], (runtime, log) => {

    const execute = (context) => {
        try {
            const script = runtime.getCurrentScript();

            log.debug({
                title: 'Governance — start',
                details: `Remaining units: ${script.getRemainingUsage()}`
            });

            // ... your processing loop

            log.debug({
                title: 'Governance — end',
                details: `Remaining units: ${script.getRemainingUsage()}`
            });

        } catch (e) {
            log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
        }
    };

    return { execute };
});

Log governance at the start of the script and at key points within loops. This will show you how quickly you're burning through units and where the expensive operations are.

RCRD_DSNT_EXIST

This means you're trying to load a record that doesn't exist, or the internal ID you're using is wrong. Common causes include hardcoded internal IDs that are valid in sandbox but not in production (or vice versa), a search returning an internal ID that was deleted, or a getSublistValue returning null that you're passing directly to record.load().

To debug this, log the ID value before the record.load() call. Verify the record exists in that environment. Never hardcode internal IDs that differ between environments. Use custom fields, configuration records, or script parameters instead.

Layer 6: Environment and Deployment Hygiene

A significant category of "bugs" aren't code bugs at all. They're environment or configuration issues. Developing good hygiene around deployment prevents a lot of wasted debugging time.

Always Develop in Sandbox

Never write or test new SuiteScript in a production account. Sandbox environments (available on NetSuite Premium and higher) are full copies of your production account where you can freely experiment without affecting live data or operations.

The workflow should always be: Develop in Sandbox, Test in Sandbox, Promote to Production.

Internal IDs Differ Between Environments

One of the most common traps for new developers: when you refresh your sandbox from production, record internal IDs usually stay in sync. But if you've created records in sandbox that don't exist in production (or vice versa), any hardcoded IDs in your script will fail in one environment.

I've found these patterns to be the safest:

  • Store environment-specific IDs in a custom record that exists in both environments with the correct values for each
  • Use script parameters on the deployment record (accessible via runtime.getCurrentScript().getParameter()) to configure IDs per environment
  • Derive IDs dynamically using searches rather than hardcoding them

Script Parameters for Configuration

Script parameters are fields you define on the script record that can be set differently on each deployment. They're perfect for things like email recipients, configuration flags, or environment-specific IDs:

/**
 * @NApiVersion 2.1
 * @NScriptType ScheduledScript
 * @NModuleScope SameAccount
 */
define(['N/runtime', 'N/log'], (runtime, log) => {

    const execute = (context) => {
        try {
            const script = runtime.getCurrentScript();

            // Read a parameter defined on the script record
            // VERIFY: 'custscript_notify_email' matches your parameter's Script ID
            const notifyEmail = script.getParameter({ name: 'custscript_notify_email' });

            log.debug({ title: 'Notify email', details: notifyEmail });

            // ... use the parameter in your logic

        } catch (e) {
            log.error({ title: e.name, details: `${e.message}\n${e.stack}` });
        }
    };

    return { execute };
});

The Deployment Status Trap

As I mentioned earlier, a deployment in Testing status only fires for administrator users. This catches new developers constantly. They test as an admin, everything works, they hand it off to a regular user, and the script silently doesn't run.

Before saying a script is working, always:

  1. Set the deployment status to Released
  2. Test as a non-admin user with the role the script is intended for
  3. Verify logs are appearing for that user's session

Layer 7: A Debugging Workflow to Follow

When a script isn't behaving as expected, resist the urge to start randomly changing code. Work through this systematic process instead.

Step 1: Confirm the Script Is Running

Check the execution log. Is there any entry for a recent execution? If not, ask yourself:

  • Is the deployment status set to Released?
  • Is the script's Status set to Active on the script record?
  • Is the correct event type configured on the deployment (e.g., CREATE + EDIT, not just CREATE)?
  • Is the deployment's record type set to the correct record?
  • Does the triggering user have a role that has access to the deployed record type?

Step 2: Confirm It Ran the Right Code Path

Look at your log entries. Did execution reach the line you expected? If not, consider whether an early return was triggered by a context guard, whether the if condition evaluated differently than you assumed, or whether a catch block swallowed the error before execution reached your code.

Step 3: Inspect the Data

Add more logging around the specific values you're working with. Are field values what you expected? Common surprises include:

  • A field returning null instead of a value
  • A number field returning a string '10' instead of 10
  • A list/select field returning an internal ID instead of the display text (or vice versa)
  • A date field returning a JavaScript Date object instead of a string

Step 4: Isolate the Failing Operation

If you have an error, use nested try/catch blocks to identify exactly which operation is throwing. Once you know the exact line, consult the API documentation for that method and verify every parameter.

Step 5: Check the Environment

If the script works in sandbox but not production (or vice versa), check for hardcoded internal IDs, feature availability differences between environments, role and permission differences, and deployment status.

Putting It All Together: A Well-Instrumented Script

Here's what a properly instrumented script looks like when all of these practices are applied:

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 * @NModuleScope SameAccount
 */
define(['N/record', 'N/runtime', 'N/log'], (record, runtime, log) => {

    const beforeSubmit = (context) => {

        // Entry log — always know if/when the script fired
        log.debug({
            title: 'beforeSubmit — entry',
            details: JSON.stringify({
                type: context.type,
                recordType: context.newRecord.type,
                recordId: context.newRecord.id,
                executionContext: runtime.executionContext
            })
        });

        // Context guard — log why we're skipping, not just that we are
        if (context.type !== context.UserEventType.CREATE &&
            context.type !== context.UserEventType.EDIT) {
            log.debug({
                title: 'beforeSubmit — skipping',
                details: `context.type is '${context.type}', expected CREATE or EDIT`
            });
            return;
        }

        if (runtime.executionContext !== runtime.ContextType.USER_INTERFACE) {
            log.debug({
                title: 'beforeSubmit — skipping',
                details: `executionContext is '${runtime.executionContext}', expected USER_INTERFACE`
            });
            return;
        }

        try {
            const rec = context.newRecord;
            const lineCount = rec.getLineCount({ sublistId: 'item' });

            log.debug({ title: 'Line count', details: lineCount });

            for (let i = 0; i < lineCount; i++) {
                const itemId = rec.getSublistValue({
                    sublistId: 'item',
                    fieldId: 'item',     // VERIFY
                    line: i
                });
                const qty = rec.getSublistValue({
                    sublistId: 'item',
                    fieldId: 'quantity', // VERIFY
                    line: i
                });

                log.debug({
                    title: `Line ${i}`,
                    details: JSON.stringify({ itemId, qty })
                });

                // Example business logic
                if (!itemId) {
                    log.debug({
                        title: `Line ${i} — skipping`,
                        details: 'itemId is null or empty'
                    });
                    continue;
                }

                rec.setSublistValue({
                    sublistId: 'item',
                    fieldId: 'memo',     // VERIFY
                    line: i,
                    value: `Processed line ${i}`
                });
            }

            log.audit({
                title: 'beforeSubmit — complete',
                details: `Processed ${lineCount} lines`
            });

        } catch (e) {
            log.error({
                title: e.name,
                details: `${e.message}\n${e.stack}`
            });
            throw e; // Re-throw so NetSuite knows the script failed
        }
    };

    return { beforeSubmit };
});

Notice a few things about this script. The entry log fires before anything else, even before context guards. Context guards log their reason for skipping before returning. The catch block captures name, message, and stack. The error is re-thrown after logging so NetSuite registers the failure rather than silently swallowing it. And an audit log at completion confirms the happy path finished.

Quick Reference: Debugging Checklist

When a script isn't behaving, run through this list.

Script not running at all:

  • Deployment status is Released
  • Script status is Active
  • Deployment is on the correct record type
  • Event type (CREATE/EDIT) matches the action you're taking
  • Triggering user's role has access to the record type

Script running but not seeing log output:

  • Deployment log level is set to DEBUG
  • Filtering execution logs to the correct time window
  • Filtering to the correct script/deployment

Script running but wrong behavior:

  • Entry log present, confirming the script fired
  • Check all context guard conditions for unexpected skipping
  • Log all variable values before using them to verify actual vs. assumed values
  • Verify all field IDs in the Records Browser

Script throwing an error:

  • Error log includes e.name, e.message, and e.stack
  • e.stack tells you the exact line number. Find it in your code.
  • Use nested try/catch to isolate the exact failing operation

Works in sandbox, not production (or vice versa):

  • No hardcoded internal IDs
  • Script parameters or configuration records used for environment-specific values
  • Feature availability verified in both environments
  • Permissions verified for production roles

Where to Go From Here

Once you're comfortable with these debugging techniques, the natural next steps are:

  • SuiteScript governance deep dive to understand unit costs by script type and strategies for high-volume processing
  • Map/Reduce scripts, which are the right tool when Scheduled scripts hit governance limits, with their own debugging considerations
  • N/search and SuiteQL for querying data efficiently, with their own set of common errors and debugging patterns

The skills you build debugging simple User Event scripts apply directly to every other script type. Invest the time to develop good logging habits now. Your future self will thank you every time a production issue lands in your lap.

All field IDs and API method signatures in this guide are marked // VERIFY where specific values are used. Always confirm against the NetSuite Records Browser and SuiteScript API Reference for your account and record types.