ComfyUI logging background¶
How ComfyUI captures, stores and ships console output. This is the machinery every comfy-env line has to travel through, and the reason worker output needs help getting there.
The rest of this page assumes you know what print() and the logging module
actually do, what sys.stdout is as distinct from file descriptor 1, and what
2>&1 means.
If any of that is fuzzy, read this page first.
Where does ComfyUI log to?¶
A single print() in the ComfyUI process can end up in three places, and a
logging call in a fourth that raw writes never reach. Only the first is
unconditional.
| # | Destination | What it is | On by default |
|---|---|---|---|
| 1 | The real terminal | the original sys.stdout / sys.stderr, written last by super().write(data) (app/logger.py) |
yes |
| 2 | A 300-entry ring | deque(maxlen=capacity), capacity=300 (app/logger.py), in memory, module-global |
yes |
| 3 | The browser terminal panel | pushed over the websocket by TerminalService, which registers on_flush(self.send_messages) (api_server/services/terminal_service.py) |
only while a client is subscribed |
| 4 | A log file | a logging.FileHandler added by setup_logger (app/logger.py); fed by logging records only, never by print() or by a traceback written to sys.stderr |
on Desktop yes, running main.py yourself no |
Which of those a line reaches depends on how it was produced, not on which
stream it names. Both streams are wrapped, each by its own LogInterceptor,
and each writes through to the stream it wrapped, so a stdout write still
ends on fd 1 and a stderr write on fd 2:
| Produced by | Terminal | Ring | Browser panel | Log file |
|---|---|---|---|---|
print(), or any write to sys.stdout |
yes, fd 1 | yes | yes | no |
any write to sys.stderr (an uncaught traceback, the warnings module, a pack writing to stderr) |
yes, fd 2 | yes | yes | no |
a logging record (ComfyUI's own [INFO] and [WARNING] lines, logging.exception) |
yes, on stderr by default; with --log-stdout, below ERROR to stdout and ERROR and above to stderr |
yes | yes | yes, at the file's own level |
The ring and the panel therefore see both streams merged in write order. The
file is fed only by the logging handler: a print() never lands there, and
neither does a traceback Python writes to sys.stderr on its own; only
logging.exception does.
Destination 4 is the one that depends on how you launched ComfyUI.
ComfyUI Desktop always has a log: <install>/logs/comfyui.log, plus
Electron's rotated main.log beside it. The Desktop wrapper produces those
around the server process; they are not app/logger.py's file.
Running python main.py yourself, you get nothing unless you ask:
python main.py --verbose DETAIL comfyui.log
It briefly worked the other way. comfyui_detail.log was on by default for one
day — added 2026-07-29 (#15064), switched off 2026-07-30 (#15159) — and before
that setup_logger had no file output at all. The if file_outputs is None
default still sitting in app/logger.py is left over from that day;
main.py always passes a real list, so it never fires.
The interception point¶
At startup ComfyUI replaces both standard streams with a wrapper:
stdout_interceptor = sys.stdout = LogInterceptor(sys.stdout) # app/logger.py
stderr_interceptor = sys.stderr = LogInterceptor(sys.stderr) # app/logger.py
This replaces the Python object, not the file descriptor. That one
sentence governs everything on the comfy-env side: sys.stdout is a name in
one interpreter's memory, while fd 1 is a kernel resource a child process
inherits. Anything reaching the terminal without passing through the
LogInterceptor object is invisible to destinations 2, 3 and 4.
Each write fans out, terminal last:
def write(self, data): # app/logger.py
entry = {"t": datetime.now().isoformat(), "m": data}
with self._lock:
self._logs_since_flush.append(entry) # -> websocket, on flush
if isinstance(data, str) and data.startswith("\r") \
and not logs[-1]["m"].endswith("\n"):
logs.pop() # \r overwrites, so drop the last
logs.append(entry) # -> the ring
super().write(data) # -> the terminal
The websocket half is deferred: flush() hands _logs_since_flush to every
registered callback and clears it, so the browser sees output in flush-sized
batches rather than per write.
The ring is a replay buffer¶
The ring exists to backfill the browser. Opening the terminal panel makes two
calls (api_server/routes/internal/internal_routes.py):
GET /internal/logs/raw -> {"entries": list(get_logs()), "size": {...}}
PATCH /internal/logs/subscribe -> start the live websocket feed
The websocket only carries what happens after subscription, so without the ring a freshly-opened panel would be blank until the next line printed — no startup banner, no earlier traceback.
Its unit is write calls, not lines. logging.info("x") costs one slot;
print("a", "b") costs four, because print writes each argument, each
separator and the newline separately. Real replay depth therefore varies with
how the code that produced it was written, and a chatty pack can push the
startup banner out of a 300-slot ring in well under 300 lines.
Defaults worth knowing¶
| Setting | Default | Flag |
|---|---|---|
| Console level | INFO |
--verbose (bare = DEBUG), or --verbose LEVEL |
| Stream for normal output | stderr | --log-stdout sends sub-ERROR records to stdout |
| Log file | none | --verbose LEVEL FILE, repeatable |
| Ring capacity | 300 writes | not exposed on the CLI |
DETAIL is a ComfyUI-specific level between DEBUG and INFO
(comfy/internal_logging.py), which is why the level list is
('DEBUG', 'DETAIL', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') rather than the
stdlib's five.
What this means for comfy-env¶
A subprocess inherits file descriptors, not Python objects. Its output lands in destination 1 and nowhere else — visible in the terminal the server was launched from, absent from the web UI. That is the whole problem comfy-env's logging has to solve, and it is why comfy-env forwards worker output over its own IPC channel instead of letting the worker write to a console.