← Back to Blog

Pro 3.7 closes your cursors — find the scripts that break before you upgrade

A folder of Python scripts and toolboxes passing through a static analysis pass that separates the files which still run from the ones that need a fix before an upgrade

Somewhere in your organization there is a folder of Python. Scripts written over several years by several people, a few .pyt toolboxes nobody has opened since the person who wrote them left, some notebooks. It runs on a schedule. Nobody can tell you with confidence which parts of it still work.

Then you plan an upgrade to ArcGIS Pro 3.7, and a change to arcpy.da cursor lifetime quietly puts some of that folder at risk.

If you are not the one writing the scripts

ArcGIS Pro 3.7 breaks a pattern that is common in older ArcPy code. If your team has scheduled scripts or toolboxes nobody has read in a few years, you do not currently know whether the upgrade breaks them — and the failure surfaces when a job runs, not when you install. Finding out takes about ten minutes with the free tool below, and produces a list of files and line numbers. Each fix is a few minutes of a developer’s time. The expensive version of this is discovering it after the upgrade, one failed overnight job at a time.

The change in one sentence. An arcpy.da cursor is now invalidated when its with block exits. Per Esri’s own Pro 3.7 Python FAQ: “the cursor object is invalidated when the context manager exists [sic] and trying to use it out of the context will now raise ValueError: I/O operation on closed file.” Any script that called reset() once the block had closed, or looped over the cursor a second time, ran fine for years and stops at 3.7. It is not deprecated, it is not warned about at runtime before the upgrade, and it fails at the point of use.

Why this counts as a fix, not a regression

The old behavior was the bug. The whole purpose of a context manager is to bound a resource’s lifetime: the whole contract of with is that the thing is live inside the block and finished outside it. A file object raises on read after close. A database cursor raises after close. arcpy.da cursors did not — you could keep using one after the block, and it worked.

That leniency is why the pattern spread. Nothing pushed back, so a second pass over the same cursor became a normal thing to write, and Esri’s FAQ is blunt that it should not have been: the pre-3.7 examples it gives are annotated “Should fail, but did not.” Aligning with standard Python behavior is the right call. It just means the bill arrives all at once, in code you did not write and cannot easily inventory.

The two shapes it takes

In real code this shows up two ways, and both are the same mistake wearing different clothes.

The explicit rewind. Somebody needed a second pass, found reset() in the reference, and used it outside the block:

with arcpy.da.UpdateCursor(fc, ["OWNER", "QA_FLAG"]) as cur:
    for row in cur:
        if not row[0]:
            row[1] = "MISSING_OWNER"
            cur.updateRow(row)

cur.reset()                      # 3.6: fine.  3.7: ValueError
count = sum(1 for _ in cur)

The accidental re-iteration. Nobody meant to rewind anything; the cursor simply outlived its block because Python let the name stay in scope:

with arcpy.da.SearchCursor(fc, ["PERMIT_ID", "STATUS"]) as sc:
    ids = [r[0] for r in sc]

for row in sc:                   # 3.6: silently yields nothing (already exhausted)
    messages.addMessage(row[1])  # 3.7: ValueError on the first pull

The second one is worth pausing on, because it was already broken before 3.7 — the cursor was exhausted, so the loop did nothing and no error was raised. The upgrade does not break that code so much as finally tell you it was never doing what it looked like it was doing. Some of what this audit surfaces will be dead logic you can delete rather than fix.

The fix

Esri’s guidance is one line: “If you need to use a cursor make a second iteration through the table you’ll want to create a fresh cursor object.” So either open a new cursor:

with arcpy.da.SearchCursor(fc, ["ID"]) as cur:
    ids = [r[0] for r in cur]

with arcpy.da.SearchCursor(fc, ["ID", "STATUS"]) as cur:   # a second, separate cursor
    statuses = {r[0]: r[1] for r in cur}

Or, better where it applies, finish the work inside the original block. Most second passes exist only because the first pass did not collect enough. Widening the field list and building what you need in one trip is usually both simpler and faster than going back to the table.

Do not reach for del here. The cursor documentation recommends del, or letting the cursor go out of scope in a function, as a guard against lock problems. That is a different concern. Deleting a cursor does not give you a second pass over it, and no amount of scope management resurrects a closed one. The answer to “I need to read it again” is a new cursor.

What the fix looks like in a real job

The abstract version above is easy to agree with and easy to forget. Here is the shape it takes in the job most teams eventually run: you have several thousand points that carry coordinates but no city and no postal code, and a service that can fill them in.

The tempting version is one loop. Open an UpdateCursor, and for each row call the service and write the answer. It works on ten rows. On four thousand it holds a schema lock on the table for the entire run, which may be hours; a failure at row 2,900 leaves partial writes and no record of where it stopped; and the natural instinct at that point — reset() the cursor and go again — is exactly the call that Pro 3.7 no longer allows.

Splitting the job in three removes all of that. The cursor is only open while the database is being read or written, never while the network is being waited on:

# Phase 1 - read the worklist. The cursor is open for milliseconds.
work = []
with arcpy.da.SearchCursor(fc, ["OID@", "LON", "LAT"], WHERE_CLAUSE) as cur:
    work = [(oid, x, y) for oid, x, y in cur]

# Phase 2 - the slow, metered part, with no cursor open and no lock held.
decisions = {}
for oid, x, y in work:
    found = lookup(x, y)
    if found:
        decisions[oid] = found

# Phase 3 - write back by OID, driven by the same WHERE_CLAUSE.
with arcpy.da.UpdateCursor(fc, ["OID@", "CITY", "POSTAL"], WHERE_CLAUSE) as cur:
    for row in cur:
        found = decisions.get(row[0])
        if found and not row[1]:   # re-check: it may have been filled
            row[1], row[2] = found["city"], found["postal"]
            cur.updateRow(row)

OID@ is what makes the split possible at all — it is the key that lets phase 3 find the row phase 1 read, without holding anything open in between. And WHERE_CLAUSE appearing in both passes is not duplication. It is the single most valuable line in the pattern: something like CITY IS NULL OR CITY = '' means a rerun picks up only what is still empty. That is what makes the job restartable after a failure, and — when the service bills per call — what stops you paying twice for rows you already have.

A working skeleton of the three phases with one function left for you to fill in — the call to whatever service you are using. It defaults to a dry run that reports what it would write, refuses to start without a WHERE_CLAUSE, and caps the number of rows per run so a first attempt cannot bill you for the whole table.

Finding them before the upgrade, not after

The fix per site is trivial. The hard part is knowing where the sites are, across a scripts share nobody has fully read. Grep is a poor instrument for it: searching for reset() misses every accidental re-iteration, and searching for cursor names returns every correct use alongside the broken ones.

What the question actually needs is scope awareness — was this name used after the block that created it closed? That is a question about the parse tree, so we wrote a small tool that asks it directly, using Python’s standard-library ast module.

It reads .py, .pyt and .ipynb files, and for each with block that opens a cursor it checks whether that name is referenced after the block ends, within the same scope. It stops at the next rebinding of the name, so a variable reused later for something unrelated is not reported. Notebook cells are joined into one unit before parsing, because a notebook shares a single namespace — open a cursor in one cell and reset it in the next and that is a real breakage which exists only across the cell boundary. It parses your source and executes nothing, which matters when you are pointing it at code you have not read.

MIT-licensed. Standard library only — no extra packages, so it runs in arcgispro-py3 or any plain Python 3.8+.

Two ways to run it, same scanner. The notebook has a single CONFIG cell: set the folder, Run All. The Pro tool is the same logic as a geoprocessing tool — drop the .pyt into a project, fill in the dialog, and each finding comes back as a tool message with a warning on the total.

Tool parameterNotebook variableWhat it controlsDefault
Folder or file to scanSCAN_PATH A single file, or a whole scripts sharerequired
Include subfoldersRECURSE Descend into subdirectoriestrue
Write report to fileREPORT_TXT / REPORT_JSON Persist the findings. The text report shortens paths to the scan root; the JSON keeps the full path on every finding, so it is the one to keep if you want to re-run after fixing and diffnone

What it looks like on a sample share

Pointed at five files — three scripts, a .pyt and a notebook — the tool returns:

Scanned 5 file(s). 5 place(s) to review:
Paths below are relative to C:\gis\scripts

  export_permits.pyt           line    16  SearchCursor  used at line 16; its `with` block closed at line 14
  notebooks/asset_audit.ipynb cell 4  line     1  SearchCursor  used at line 1; its `with` block closed at cell 3 line 2
  notebooks/asset_audit.ipynb cell 4  line     2  SearchCursor  used at line 2; its `with` block closed at cell 3 line 2
  weekly_parcel_qa.py          line    12  UpdateCursor  used at line 12; its `with` block closed at line 10
  weekly_parcel_qa.py          line    13  UpdateCursor  used at line 13; its `with` block closed at line 10

Each of these is a place to LOOK, not a confirmed bug - the check follows a name, so a
name reused for something this tool cannot trace will appear here. Where the finding is
real, at Pro 3.7 it raises ValueError: I/O operation on closed file. The fix is a fresh
cursor for the second pass, or finishing the work inside the original `with` block.

Three of the five files need work. The notebook line and the two silent files are the two results worth dwelling on.

The notebook finding spans cells — the block closes in cell 3, the cursor is used in cell 4. That is the most common real form of this bug in a notebook, and it exists only because a notebook shares one namespace across cells. A checker that reads cells one at a time cannot see it and reports the file clean.

And the two files it does not mention are the point. One opens two separate cursors correctly. The other rebinds the name cur to a file handle after the block — the exact case that makes a text search report a problem that is not there.

What it cannot see

This is static analysis, and being straight about the edges is the difference between a useful audit and a false sense of coverage. It is scope-aware — a function whose parameter happens to be called cur is not mistaken for a cursor in another function — and it recognizes aliased imports such as from arcpy import da as _da. What it does not reach:

So read the output as a review list rather than a verdict — which is what the tool itself says when it prints. An empty result means nothing was found in the shapes it understands. It is not a certification, and the tool does not claim to issue one.

The rest of the 3.7 upgrade, which lands on the same people

Cursor scope is one item on a 3.7 checklist, and it would be misleading to publish an audit for it without naming the others — several are upgrade blockers rather than code fixes, and they reach the same person in the same week. From the Pro 3.7 release notes:

The release notes list further changes — Data Reviewer workspace-based quality workflows are no longer supported; the built-in help viewer and Utility Network Version 3 are deprecated. Read them properly rather than treating this article as the checklist. And if you are jumping several releases at once rather than moving 3.6 to 3.7, everything in the intervening versions arrives with it.

One caveat on the reference pages. At the time of writing, the SearchCursor and UpdateCursor class references still describe with support as being there “to reset iteration and aid in removal of locks” and do not mention the invalidation or the ValueError. If you go to the class reference to confirm this article, you will find text that reads like a contradiction. The 3.7 Python FAQ is the current source on the behavior change.

Where this lands

Run the audit before you schedule the upgrade rather than after, so the fix list is an input to the plan instead of a surprise during it. Remediation per site is small and mechanical; the discovery is what takes time if you do it by reading. Test the fixes against a copy of the data — the UpdateCursor path rewrites rows.

And if the audit turns up nothing, that is a useful answer too, as long as you hold it alongside the blind spots above — one fewer unknown in front of an upgrade nobody wants to do twice.

References

Facing a Pro upgrade with a scripts folder nobody fully owns?

We audit and modernize ArcPy estates — find what breaks, fix it, and leave you with tooling that answers the question again next release instead of another manual read-through.

Book a free intro call