2025, Oct 02 05:00
Why Tkinter Canvas Images Disappear in Modular Apps and How to Keep PhotoImage Alive
Tkinter Canvas image not showing after modularization? Learn how garbage collection removes PhotoImage and fix it with persistent references or Toplevel.
When modularizing a Tkinter app, a deceptively simple refactor can break image rendering on a Canvas. The window appears, widgets are created, but the logo never shows up. The root cause is not in Canvas or PIL at all, but in object lifetimes: the window object that owns the image is not kept alive, so Python garbage collects it along with the PhotoImage reference.
Reproducing the issue
Below is a minimal structure that mirrors the failing setup: a class in a separate module that builds a Toplevel and draws an image on a Canvas, and a main launcher that creates the window without storing a reference.
from tkinter import *
from PIL import ImageTk, Image
class CreateSecretDialog:
    def __init__(self, parent_win):
        self.parent_win = parent_win
        self.top_win = Toplevel(self.parent_win)
        self.tk_logo = ImageTk.PhotoImage(Image.open("password_img.png"))
        self._setup_shell()
        self._build_canvas()
    def _setup_shell(self):
        self.top_win.title("Add a password")
        self.top_win.config(padx=50, pady=50)
    def _build_canvas(self):
        surface = Canvas(self.top_win, width=205, height=205)
        surface.create_image(102, 102, image=self.tk_logo)
        surface.grid(column=0, row=0)
from tkinter import *
import child_windows
# Launch this when the button is clicked
def spawn_dialog():
    child_windows.CreateSecretDialog(app_root)
# Root UI
app_root = Tk()
app_root.title("Password Manager")
app_root.config(padx=15, pady=15)
btn_new = Button(app_root, command=spawn_dialog, text="Add Password")
btn_new.grid(column=0, row=0)
app_root.mainloop()
What actually goes wrong
The dialog instance is created and immediately goes out of scope because it’s not stored anywhere. When that happens, Python’s garbage collector is free to tear it down. Since the PhotoImage reference lives on that short-lived object, it also gets collected. Tkinter keeps the Canvas, but the underlying image is gone, so nothing is displayed even though the Canvas itself is visible.
Fixes that keep the image alive
You can resolve this by keeping at least one persistent reference alive. There are three straightforward ways to do that without changing your UI design.
The most direct approach is to retain the dialog object at the module level. That keeps the owning object (and its PhotoImage) from being collected.
# child_windows.py stays the same as above
from tkinter import *
import child_windows
add_secret_view = None  # module-level holder
def spawn_dialog():
    global add_secret_view
    add_secret_view = child_windows.CreateSecretDialog(app_root)
app_root = Tk()
app_root.title("Password Manager")
app_root.config(padx=15, pady=15)
btn_new = Button(app_root, command=spawn_dialog, text="Add Password")
btn_new.grid(column=0, row=0)
app_root.mainloop()
If you prefer to avoid a global dialog reference, bind the image to a long-lived widget. Assigning it as an attribute on the Canvas (or on the Toplevel or even the root) ensures the object stays referenced for as long as that widget exists.
from tkinter import *
from PIL import ImageTk, Image
class CreateSecretDialog:
    def __init__(self, parent_win):
        self.parent_win = parent_win
        self.top_win = Toplevel(self.parent_win)
        self.tk_logo = ImageTk.PhotoImage(Image.open("password_img.png"))
        self._setup_shell()
        self._build_canvas()
    def _setup_shell(self):
        self.top_win.title("Add a password")
        self.top_win.config(padx=50, pady=50)
    def _build_canvas(self):
        surface = Canvas(self.top_win, width=205, height=205)
        surface.create_image(102, 102, image=self.tk_logo)
        surface.grid(column=0, row=0)
        # keep a persistent reference on a widget
        surface.tk_logo = self.tk_logo
        # alternatively:
        # self.top_win.tk_logo = self.tk_logo
        # self.parent_win.tk_logo = self.tk_logo
Another clean option is to make the dialog itself a Toplevel subclass. In that shape it’s a proper widget, and using self for all operations is more natural. The PhotoImage remains attached to a widget instance that Tkinter manages.
from tkinter import *
from PIL import ImageTk, Image
class CreateSecretDialog(Toplevel):
    def __init__(self, parent_win):
        super().__init__(parent_win)
        self.tk_logo = ImageTk.PhotoImage(Image.open("password_img.png"))
        self._setup_shell()
        self._build_canvas()
    def _setup_shell(self):
        self.title("Add a password")
        self.config(padx=50, pady=50)
    def _build_canvas(self):
        pad = Canvas(self, width=205, height=205)
        pad.create_image(102, 102, image=self.tk_logo)
        pad.grid(column=0, row=0)
Why this matters in real projects
As soon as a Tkinter app grows beyond a single file, object lifetimes stop being obvious. Creating a widget-holding class without storing it anywhere is a subtle footgun, because it takes the image objects down with it. Understanding that PhotoImage must be referenced for as long as it’s displayed prevents hours of debugging invisible canvases. If your linter complains about a global holder being undefined, initializing it to None at the module level silences the warning while keeping the design clear.
Takeaways
Keep an object alive if it owns resources you still need on screen. Either retain the dialog instance in a long-lived variable, bind the PhotoImage to a widget attribute like Canvas or Toplevel, or make the dialog inherit from Toplevel and keep the image as an instance attribute. All three approaches ensure the image outlives any short-lived local scopes and reliably renders on your Canvas.
The article is based on a question from StackOverflow by Nabodita Gangully and an answer by furas.