MediumThe QThread Gotcha: Why the Same AutoConnection Behaves Differently for the Same "Thread" | by Anuj Gupta | Medium
Sitemap
Anuj Gupta

Anuj Gupta

Senior Technical Lead, 11+ years in safety-critical C++ — aviation, medical devices, industrial automation. Writes about build tools and shipping software.

The QThread Gotcha: Why the Same AutoConnection Behaves Differently for the Same "Thread"

4 min readAug 12, 2026

--

Here’s a scenario that trips up even experienced Qt developers: you create a worker object, move it to a thread, connect a couple of signals with the default connection type, and assume both connections behave the same way because they’re both “about” the same thread. They don’t. One becomes a DirectConnection. The other becomes a QueuedConnection. Same Qt::AutoConnection, same code style, completely different execution behavior — and the reason comes down to a detail most people never think to question: the QThread object does not live in the thread it manages.

Setting the Scene

class Worker : public QObject {
Q_OBJECT
public slots:
void doWork() {
qDebug() << "doWork running on:" << QThread::currentThread();
}
};
class MyThread : public QThread {
Q_OBJECT
public slots:
void onStarted() {
qDebug() << "onStarted running on:" << QThread::currentThread();
}
};
MyThread thread;              // created in main thread
Worker worker; // created in main thread
worker.moveToThread(&thread); // worker's affinity is now 'thread'
QObject::connect(&thread, &QThread::started,
&worker, &Worker::doWork); // AutoConnection (default)
QObject::connect(&thread, &QThread::started,
&thread, &MyThread::onStarted); // AutoConnection (default)
thread.start();

Both connections use the default Qt::AutoConnection. Both are triggered by the exact same signal, QThread::started, emitted at the exact same moment. Intuitively, you'd expect both slots to run "in the thread," together, consistently. They don't.

What Actually Happens

doWork running on: QThread(0x...) named "thread"      // ran on the worker thread — QUEUED
onStarted running on: QThread(0x...) "main" // ran on the MAIN thread — DIRECT

doWork() runs on the new worker thread, as expected. onStarted() — a slot defined inside the QThread subclass itself — runs on the main thread, not the thread it's supposedly managing. To most people encountering this for the first time, that looks backwards.

Get Anuj Gupta’s stories in your inbox

Join Medium for free to get updates from this writer.

Why This Happens: Thread Affinity Is About the Object, Not What It Represents

Qt::AutoConnection resolves to DirectConnection if sender and receiver share the same thread affinity at the moment the signal is emitted, and QueuedConnection otherwise. So the whole mystery comes down to one question: what thread does each receiver actually live in?

  • worker.moveToThread(&thread) explicitly reassigns worker's thread affinity to thread. From that point on, worker "lives in" the new thread as far as Qt's event system is concerned. When QThread::started fires from thread's own execution context, sender (thread) and receiver (worker) are on different threads → AutoConnection resolves to QueuedConnection. The call gets posted to worker's thread event loop, and doWork() correctly executes on the worker thread.
  • thread itself — the QThread instance — was never moved anywhere. A QThread object's own thread affinity, like any QObject, is whichever thread created it, and it stays there unless you explicitly call moveToThread() on it. Nobody did. So thread still lives in the main thread. When QThread::started fires, sender (thread) and receiver (thread, i.e. itself) are the same object on the same thread → AutoConnection resolves to DirectConnection, and onStarted() runs synchronously on the main thread, at the exact moment start() triggers the signal.

This is the counterintuitive part worth internalizing: a QThread object represents a thread of execution, but the object itself is not "in" that thread. The code running inside run() executes on the new thread. The QThread object's own slots, unless you go out of your way to move the object itself, do not.

The Classic Mistake This Causes

A very common (and very old) Qt anti-pattern is subclassing QThread and putting your actual work logic directly in overridden slots or in run(), connecting things to the QThread object as if it were the worker:

class BadWorkerThread : public QThread {
Q_OBJECT
protected:
void run() override {
// this part is fine — run() genuinely executes on the new thread
doHeavyWork();
}
public slots:
void onSomeSignal() {
// this slot, if connected with AutoConnection from a main-thread
// signal, runs on the MAIN thread — not here, even though it's
// defined inside a QThread subclass
processResult();
}
};

The confusion compounds because run() genuinely does execute on the new thread — it's the one method QThread guarantees runs there — which makes it easy to wrongly assume everything attached to that QThread object behaves the same way. It doesn't. This exact trap is a big part of why the Qt documentation and community moved firmly toward the worker-object pattern instead of subclassing QThread at all.

The Pattern That Avoids the Confusion Entirely

QThread* thread = new QThread;
Worker* worker = new Worker;
worker->moveToThread(thread);
QObject::connect(thread, &QThread::started, worker, &Worker::doWork);
QObject::connect(worker, &Worker::finished, thread, &QThread::quit);
QObject::connect(thread, &QThread::finished, worker, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater);
thread->start();

Here, QThread is used purely as a thread-management handle — start it, stop it, clean it up — and never as a place to put actual business logic or slots you expect to run "inside" the thread. All real work lives in Worker, which was explicitly moved with moveToThread(). Every connection's behavior becomes predictable, because there's no ambiguity left about which object lives where.

Takeaway

Qt::AutoConnection isn't inconsistent — it's doing exactly what it's documented to do: compare the sender's and receiver's actual thread affinity at emit time. The trap is assuming a QThread object shares the affinity of the thread it manages. It doesn't, unless you explicitly move it there yourself, which almost nobody does and almost nobody should. The fix isn't a special case to memorize — it's a mental model correction: QThread is a handle to a thread, not a resident of it. Once that clicks, this entire class of "why did my slot run on the wrong thread" bug stops being mysterious.

--

--

Anuj Gupta
Anuj Gupta

Senior Technical Lead, 11+ years in safety-critical C++ — aviation, medical devices, industrial automation. Writes about build tools and shipping software.