Pro 3.7 closes your cursors — find the scripts that break before you 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.
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.
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 parameter | Notebook variable | What it controls | Default |
|---|---|---|---|
| Folder or file to scan | SCAN_PATH |
A single file, or a whole scripts share | required |
| Include subfolders | RECURSE |
Descend into subdirectories | true |
| Write report to file | REPORT_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 diff | none |
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:
- Cursors that leave the name. One stored in a list, dict or attribute, passed into a function, or returned from one. If your codebase wraps cursors in helper classes, this under-reports and you will need to read those helpers yourself.
- Cursors used from a different scope than the one that opened them —
inside a nested function, a lambda, or via
global/nonlocal. Scope awareness is what removes the false positives, and these false negatives are what it costs. That is the trade, and it is worth knowing which side of it you are on. - Python inside
.atbxand.tbxtoolboxes. Those are binary containers rather than source files. If your cursor code lives in script tools packaged that way, a clean result here says nothing about them. - Anything depending on runtime values, because nothing is executed.
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:
- Concurrent Use licenses are no longer supported. A licensing migration, not a code change, and it gates the whole upgrade.
- .NET 10 Desktop Runtime is required in place of .NET 8 — a deployment gate on locked-down machines.
- Connections to a user-schema geodatabase in Oracle are no longer supported. That data has to move first — before the upgrade, not after.
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.
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
- Python in ArcGIS Pro 3.7 FAQ — Esri Community (the cursor invalidation change, the
ValueErrortext, and the fresh-cursor guidance quoted above) - SearchCursor — ArcPy reference (
withsupport,reset(), and thedeladvice that addresses locks rather than scope) - UpdateCursor — ArcPy reference (same lifetime rules for the editing cursor)
- InsertCursor — ArcPy reference (the third cursor class the audit recognizes)
- What’s new in ArcGIS Pro 3.7 (release context for the upgrade this audit is meant to precede)
- ArcGIS Pro 3.7 release notes (Concurrent Use licensing, the .NET 10 requirement, the Oracle user-schema removal, and the rest of the breaking changes)
ast— Python standard library (the parser the tool uses; nothing in the scanned code is executed)- The
withstatement — Python language reference (the context-manager contract Pro 3.7 now honors)
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