Skip to main content
Use PYTHON_SANDBOX_RUN for a step that needs Python code. The required code input runs in a sandbox. The value of the last expression is the result, with text printed to stdout as the fallback; data_type controls whether it is parsed as STRING (default), NUMBER, or OBJECT. For OBJECT, end the code with json.dumps(...) as the last expression (or print it with print(json.dumps(...))): a bare dict or list comes back as a Python repr, which is not valid JSON.

Calculate a number from earlier steps

The result is result.data, which should be 3 in this example. Inspect logs and error if the step fails. A NUMBER result must parse as a number; use STRING for ordinary text or OBJECT for JSON-compatible data.

Install packages and return an object

Add Python packages in the step’s config when the code needs a library. This example totals sales rows from an earlier step with pandas and returns a JSON object.
With OBJECT, the JSON printed to stdout is parsed, so later steps read {{steps.summarize.result.data.grand_total}} (250) or {{steps.summarize.result.data.totals.east}} (170). Wrap step output in json.loads(r'''...''') as shown: pasting JSON directly into Python code breaks on null, true and false.

In an organization with an egress policy

In an organization with an egress policy (a restriction on which external hosts sandboxes may reach), the sandbox is locked down before your code runs, and the code runs without administrator rights:
  • There is no sudo, /usr/local is read-only and /root is not accessible. Install system packages with the dependencies config, which runs before your code with the rights it needs.
  • packages install before the lockdown, so they need no allowed host. A pip install in your code runs after it and needs the package index (pypi.org, files.pythonhosted.org) on the allowed hosts. Both install into the user’s own site directory (~/.local) and import as usual; for pip inside a virtual environment you create yourself, set PIP_USER=0.
  • Every external hostname resolves to the placeholder address 198.18.0.1 inside the sandbox. HTTP and HTTPS requests to allowed hosts still work, because the sandbox routes them by hostname; code that needs a host’s real IP address does not, and a request to an IP address instead of a hostname is refused.
  • Only HTTP and HTTPS (ports 80 and 443) to allowed hosts get out. Other protocols, and plain DNS queries, are blocked.

Read step data and secrets

  • Previous step data is available as steps.{step_name}.result.{key}; secrets as secrets.{secret_name}.{key} (e.g. secrets.OAUTH.access_token). For single-key secrets, str(secrets.{secret_name}) returns the value directly.
  • Only the secrets your code names are sent to the sandbox. Reference each one by a literal name — secrets.NAME, secrets["NAME"] or secrets.get("NAME") (f-string fields such as f"Bearer {secrets.NAME}" count). A computed key (secrets[name]) or a whole-map read such as secrets.items() or json.dumps(secrets) is rejected before the sandbox starts.

Large tables: tables

A step output travels between steps as JSON. A DataFrame with hundreds of thousands of rows does not survive that: serializing it exhausts the sandbox’s memory before the step can return. Every Python step is given a tables helper for this. tables.write(df) publishes the frame to storage and returns a small marker — a few hundred bytes naming the stored table — which becomes the step’s result. The next step calls tables.read(...) on that marker and gets the DataFrame back. The rows never become JSON.
INPUT_FILE returns the file URL as a string, so steps.csv_file.result is the URL itself and pd.read_csv reads it directly. The marker is a dict, so the step has to return it the way it returns any other structured result: json.dumps(...) as the last expression, with data_type: OBJECT. Without that the step’s data_type defaults to STRING and the next step receives the dict’s Python text, which tables.read cannot open. tables.write(df, name="result", fmt="csv", index=None) publishes a frame and returns its marker.
  • name — the stored object’s name, so one step can publish several tables. Letters, digits, ., _ and - only, with no .. and no leading or trailing dot.
  • fmt — "csv" (the default) or "parquet". Parquet needs pyarrow, which is not installed in every sandbox image; tables.write says so if it is missing.
  • index — whether to write the frame’s index as columns. The default, None, writes it when it carries data: the grouping keys of groupby(...).agg(...) or pivot_table, a DatetimeIndex from resample. A plain positional index is dropped, so a filtered frame does not gain a stray column. Pass True or False to decide it yourself.
Columns must be single-level. Flatten a pivot with several values first: df.columns = ["_".join(str(part) for part in column) for column in df.columns.to_flat_index()]. df must be a DataFrame, not a Series. df.groupby([...])["amt"].sum() returns a Series — call .to_frame() on it first; its grouping keys are a named index and are written as columns. tables.read(marker) returns the DataFrame. The frame comes back flat: everything tables.write serialized is a column, including levels that were an index on the way out. marker["__jinbatable"]["columns"] lists them, along with rows and sizeBytes, so a later step can see the shape without fetching the table. rows is also checked against the frame that actually parsed: an object cut short mid-upload is refused here rather than handed on as a quietly shorter table. tables.is_table(value) reports whether a value is a marker, for a step that accepts either a table or inline rows. Size limit. One tables.write call is bounded by what the deployment accepts in a single upload, and that bound belongs to the deployment rather than to the tool: on the managed service the platform ingress rejects a request body over 32 MiB, while a self-hosted deployment may have a different ceiling, or none, and may instead cut a slow upload on a request timeout. tables.write reports whichever happened — a frame refused for its size, or a connection closed before any status came back — and names the limit when the deployment has declared one. Either way: aggregate or filter before writing, or publish several smaller tables with different names. Reading is not bounded this way: a table already in storage can be read back whole, subject only to the sandbox’s memory — which for a very large frame is the binding constraint, because tables.read materializes the whole table at once. Notes:
  • A table is readable by later steps of the same flow, including reused steps replaying an earlier run’s output. It is not readable from another flow.
  • Inside a forEach, each iteration gets its own storage slot, so every iteration can call tables.write(df) with the default name without overwriting its siblings.
  • tables.read also accepts a marker arriving as the item of a forEach.
  • Small results need none of this: a step that returns rows still returns rows.