Quick answer: Pygame locks a surface when you access its pixels via pixels_alpha, pixels3d, or direct locking. The surface stays locked until every Python reference to the returned array is released. Blitting a locked surface raises pygame.error: Surfaces must not be locked during blit. Use del pxarr before the blit, or switch to surfarray.array3d which returns a copy.
Here is how to fix the Pygame error Surfaces must not be locked during blit. Your code runs fine in a small test, then crashes the moment you introduce per-pixel effects. The traceback points at a blit call that has nothing obviously wrong with it. The issue is earlier in the frame, where a pixel array you obtained is still holding the surface hostage.
The Symptom
You are building a sprite effect: maybe fading alpha, maybe a dynamic tinting system, maybe a procedural texture. You reach for pygame.surfarray.pixels3d or pygame.surfarray.pixels_alpha because it is the fastest way to touch pixel data. You manipulate the array, then blit the surface onto the display. Pygame immediately raises:
pygame.error: Surfaces must not be locked during blit
Sometimes the error comes from blitting the surface you just edited; sometimes it comes from blitting anything, because the display surface was the one you locked. Sometimes the error happens only occasionally, depending on whether Python garbage collection runs between statements. The inconsistency makes it look like a flaky engine bug, but the lock state is deterministic — it just depends on reference lifetimes.
What Causes This
Pygame surfaces store pixel data in native memory. When Python code wants raw access, the surface has to be “locked” so the pixel buffer pointer stays valid. The pixels_alpha, pixels2d, pixels3d, and pixels_red family all return numpy ndarrays whose underlying buffer points into the locked surface. As long as that ndarray exists, the lock is held.
Blitting requires the engine to reacquire the surface, potentially with hardware acceleration, and the pixel buffer pointer may change (for example, when moving data to VRAM). Pygame refuses to blit a locked surface because doing so could result in a use-after-free or corrupt pixels.
The subtle part: the lock is released when the ndarray is garbage collected, not when it goes out of scope in pure CPython semantics. If you rebind the name to a new array, the old one is eligible for collection but may not be collected immediately if something else holds a reference — for example, a slice you took, a numpy view, a debugger reference, or a lingering entry in the local frame’s variable dictionary.
Another cause: calling surface.lock() manually and forgetting to call surface.unlock(). Some Pygame operations auto-unlock, but if your code took the lock explicitly, only you can release it.
The Fix
Step 1: Release the pixel view before blitting. The most reliable pattern is to wrap pixel access in a tight block and explicitly del the array:
import pygame
import pygame.surfarray as surfarray
def tint_surface(surf, color):
pxarr = surfarray.pixels3d(surf)
pxarr[:, :, 0] = (pxarr[:, :, 0] * color[0] // 255).astype('uint8')
pxarr[:, :, 1] = (pxarr[:, :, 1] * color[1] // 255).astype('uint8')
pxarr[:, :, 2] = (pxarr[:, :, 2] * color[2] // 255).astype('uint8')
del pxarr # release the lock before returning
return surf
# Safe to blit — the lock was released inside tint_surface
screen.blit(tint_surface(sprite.copy(), (255, 128, 128)), (10, 10))
If you forget the del, the array may survive until the next GC cycle and cause the blit to fail intermittently.
Step 2: Prefer array3d over pixels3d when possible. If you only need to read pixels or work on a copy, use surfarray.array3d(surf). This returns a brand-new ndarray disconnected from the surface, so no lock is held:
# array3d returns a copy — no lock, but slower and uses more memory
arr = surfarray.array3d(surf)
arr[:, :, 0] = 255 # modifying the copy, surface is unchanged
# Push the edited pixels back with blit_array
surfarray.blit_array(surf, arr)
screen.blit(surf, (0, 0)) # works fine, no lock held
Step 3: Call convert() once on load, not every frame. A common trap: devs call surface.convert() inside the draw loop to fix a perceived format issue, but convert() does not release existing locks. The right pattern is to convert at load time and cache the result:
# At load time, not per frame
sprite = pygame.image.load("player.png").convert_alpha()
Step 4: Balance manual locks. If you call surface.lock(), match it with surface.unlock() in a try/finally block so exceptions do not leave the surface in a locked state:
surf.lock()
try:
# direct access to surf.get_buffer() or C-extension operations
pass
finally:
surf.unlock()
Use surf.get_locked() to check the lock state during debugging. Print it before every blit in your hot path to identify exactly which surface is still locked when the error fires.
Why This Works
The lock is Pygame’s guarantee that the raw pixel pointer stays valid across Python calls. When you drop the ndarray reference, Pygame’s reference count hits zero and the surface is unlocked atomically. From that moment, blit and other operations can move pixels freely again.
Using array3d for read and blit_array for write splits the work into two safe operations with no lock overlap. This is slightly slower than pixels3d because you pay for a copy, but it is immune to the locking bugs that plague shared-buffer code.
Related Issues
If your Pygame sprite images have slow per-frame blit performance despite being small, see Fix: Pygame Slow Blit Performance with convert_alpha. The usual fix is converting once at load time.
For Pygame surfaces showing unexpected transparency or color channel issues, see Fix: Pygame Transparency and Color Key Not Working.
Every pixel view holds a lock.del the array, or use array3d. The blit only succeeds when the surface is unlocked.