Why does a k6 script that calls open() inside an if (__VU === 1) branch fail once VUs start?
answer
- two init passes, not one
- the first pass records the paths
- filesystem locks after load time
- open unconditionally, slice per VU
basics
~20 sk6 records every file path opened during its load-time init pass, where __VU is 0, then locks the filesystem. An open() behind a __VU condition is skipped in that pass, so the later per-VU init reads an unrecorded path and fails.
solid answer
~40 sk6 evaluates the init context once at load time in a throwaway runtime numbered `__VU === 0`, and while that pass runs it records every file path requested. The moment it finishes, k6 switches its filesystem to a cached-only mode. Each VU then re-runs the same module scope against that locked filesystem. With the `open()` hidden behind `if (__VU === 1)`, the load-time pass skips it, nothing is recorded, and VU 1's own init hits *"open() can't be used with files that weren't previously opened during initialization (__VU==0)"*. The fix is to open unconditionally at module scope and branch on `__VU` only when choosing which record to use.
code
javascript · 11 lines// Fails: the branch is false during the load-time pass, so the path is never recorded
// let users = [];
// if (__VU === 1) { users = JSON.parse(open('./users.json')).users; }
// Works: opened unconditionally at module scope, chosen per VU at run time
const users = JSON.parse(open('./users.json')).users;
export default function () {
const user = users[(__VU + __ITER) % users.length];
console.log(user.username);
}go deeper
Take away the rule of thumb: call open() at the top of the script with no if around it, and never inside a condition.
Explain the two passes — a load-time pass at __VU 0 that records paths, then per-VU passes that run against a locked filesystem.
Diagnose from the symptom: the error names __VU==0 while __VU is not zero, which places the failure in a later pass than the recording one.
Judge the constraint's reach: a fixed file closure rules out per-VU data files, so large or per-user datasets have to come from setup() or an external source.
## The failure The script looks defensive and reasonable: ```javascript let users = []; if (__VU === 1) { users = JSON.parse(open('./users.json')).users; // never reached at load time } ``` k6 starts, and then the run dies while it is initializing VU 1 with: `open() can't be used with files that weren't previously opened during initialization (__VU==0), path: "/work/users.json"` The confusing part is that `open()` *is* being called from the init context — the branch is at module scope, exactly where `open()` is supposed to live. The placement is right. The problem is *when*. ## Two passes, and a lock between them k6 evaluates the init context more than once, and the first pass is special. 1. **The load-time pass.** Before any VU exists, k6 evaluates the whole script once in a throwaway runtime numbered `__VU === 0`. This is the pass that reads the exported `options` and discovers the callable exports. While it runs, k6's filesystem layer **remembers every path that was requested**. 2. **The lock.** As soon as that pass finishes, k6 switches the filesystem into a cached-only mode. From then on, a request for a path that was never recorded returns "path never requested before", which `open()` turns into the error above. k6 locks its module resolver at the same moment and for the same reason. 3. **The per-VU passes.** Each VU then re-runs the same module scope in its own runtime — but now against the locked filesystem. In the script above, `__VU` is `0` during the load-time pass, so the branch is false and `users.json` is never recorded. When VU 1 initializes, `__VU` is `1`, the branch is finally taken, and the read hits a filesystem that has been closed to new paths. | What you assumed | What k6 actually does | |---|---| | init runs once, when the VU starts | it runs once at load time and again per VU | | `__VU` is 0 for the whole init stage | it is 0 only in the load-time pass, then the VU's own id | | `open()` reads the disk whenever it is called | it reads only paths recorded by the load-time pass | ## Why k6 works this way The lock is not a bug being worked around; it is what makes a k6 script's file set knowable. - **The file closure is fixed before the test starts.** k6 can state exactly which local files a test depends on, because they were all touched in one deterministic pass with no runtime branching. - **The run stays reproducible.** No VU can observe a file that another VU did not, and no VU can see a version of a file written after the test began. - **Distribution works.** A test whose file set is known up front can be shipped to another machine or another instance with everything it needs. The source comment on the guard says it plainly: loading different files per VU is not supported, so every file used in a scenario must be opened during the init step **without any conditions**. ## The fix Open unconditionally at module scope, then decide per VU what to *use*: ```javascript const users = JSON.parse(open('./users.json')).users; // always runs, always recorded export default function () { const user = users[(__VU + __ITER) % users.length]; // ... } ``` The same shape covers the cases people reach for conditional opens to solve: - **Different data per VU** — open one fixture and slice it by `__VU`, rather than opening one file per VU. - **Environment-dependent fixtures** — if `__ENV` selects between two files, open *both* unconditionally and pick between the parsed results, or build the filename from `__ENV` so the same single `open()` call runs in every pass with the same argument. - **Optional data** — there is no supported way to make a local file read optional at runtime. Either it is part of the test's declared file set or it is not, and a `try/catch` around the call does not change that: the retry fails for the same reason the first attempt did. ## The tell in an interview Two symptoms mark this bug, and both are worth naming out loud: the failure appears **after** k6 has already printed its startup banner rather than at parse time, and it names `__VU==0` in an error thrown while `__VU` is plainly not zero. Both point at the same thing — the recording pass and the failing pass are different passes. Once you can say that sentence, the fix follows immediately, and so does the wider rule it belongs to: in k6, what init touches must not depend on which VU is running it.
- Does the same locking apply to modules imported by a k6 script?Yes. k6 locks its module resolver right after the load-time init pass, for the same reason and with a matching message: *"the module ... was not previously resolved during initialization (__VU==0)"*. A dynamic import reached only on a later branch fails the same way an unrecorded `open()` does.
- How do you give each k6 VU different data if open() cannot vary per VU?Open the whole fixture once at module scope and index into it with `__VU`, or with `exec.vu.idInTest` inside the iteration. Every VU parses the same file, but each selects a different slice. For genuinely dynamic data, fetch it in `setup()` and return it, since `setup()` runs with full VU state.
- Why does k6 lock the filesystem instead of simply allowing later reads?Because the recorded set is the test's declared file closure. Fixing it in one deterministic pass keeps runs reproducible, stops two VUs seeing different bytes for the same path, and lets a script be shipped to another machine or instance with everything it needs already known.
Like an archive that photocopies every document requested during a scheduled reading hour and then seals the vault: whatever nobody asked for in that hour can never be fetched afterwards, however legitimate the later request looks.
saying these in an interview costs you the question
- Blames a missing file or a wrong relative path
- Thinks __VU is always 0 while init code runs
- Suggests wrapping the open() in a try/catch and retrying
- Believes each VU may read its own separate fixture file
- Assumes the error means open() was called from VU code