ReactiveX/RxJava · error · NullPointerException

Operator ${operator} returned a null Subscriber

Error message

Operator ${operator} returned a null Subscriber

What it means

Thrown during subscription when a user-supplied FlowableOperator (passed to Flowable.lift() or used internally by operators like compose/transform) returned null from its apply(Subscriber) method. RxJava treats a null Subscriber as a contract violation because the Reactive-Streams spec requires a real Subscriber to receive signals; it surfaces this as a NullPointerException rather than forwarding null downstream.

Source

Thrown at src/main/java/io/reactivex/rxjava4/internal/operators/flowable/FlowableLift.java:46

 * @param <T> the upstream value type
 * @param <R> the downstream parameter type
 */
public final class FlowableLift<R, T> extends AbstractFlowableWithUpstream<T, R> {
    /** The actual operator. */
    final FlowableOperator<? extends R, ? super T> operator;

    public FlowableLift(Flowable<T> source, FlowableOperator<? extends R, ? super T> operator) {
        super(source);
        this.operator = operator;
    }

    @Override
    public void subscribeActual(Subscriber<? super R> s) {
        try {
            Subscriber<? super T> st = operator.apply(s);

            if (st == null) {
                throw new NullPointerException("Operator " + operator + " returned a null Subscriber");
            }

            source.subscribe(st);
        } catch (NullPointerException e) { // NOPMD
            throw e;
        } catch (Throwable e) {
            Exceptions.throwIfFatal(e);
            // can't call onError because no way to know if a Subscription has been set or not
            // can't call onSubscribe because the call might have set a Subscription already
            RxJavaPlugins.onError(e);

            NullPointerException npe = new NullPointerException("Actually not, but can't throw other exceptions due to RS");
            npe.initCause(e);
            throw npe;
        }
    }
}

View on GitHub (pinned to a8ab535614)

Solutions

  1. Ensure FlowableOperator.apply() never returns null on any path; always return a valid Subscriber (even a no-op like a pass-through).
  2. If the operator must be conditional, return the downstream Subscriber unchanged rather than null when bypassing.
  3. Add unit tests asserting apply() returns non-null for every input branch.
  4. Use Operators.serialize or a wrapper Subscriber as the default fallback instead of null.

Example fix

// before
FlowableOperator<Integer, Integer> op = child -> {
    if (!enabled) return null; // -> NPE on subscribe
    return new MapSubscriber<>(child, x -> x + 1);
};
// after
FlowableOperator<Integer, Integer> op = child -> {
    if (!enabled) return child;  // pass-through, never null
    return new MapSubscriber<>(child, x -> x + 1);
};
Defensive patterns

Strategy: validation

Validate before calling

// Validate a custom FlowableOperator never returns null before use.
FlowableOperator<R, T> op = /* your operator */;
FlowableOperator<R, T> safeOp = child -> {
    Subscriber<? super T> s = op.apply(child);
    if (s == null) {
        throw new NullPointerException("Operator " + op + " returned null Subscriber");
    }
    return s;
};
// Better: unit-test every branch of apply() for non-null output before wiring it in.

Type guard

static <T, R> boolean operatorNeverReturnsNull(FlowableOperator<R, T> op) {
    // Best-effort static check: inspect that no 'return null;' literal exists;
    // runtime guarantee requires a test subscriber probe per branch.
    return op != null; // a real guarantee needs branch-coverage tests, see validationCode
}

Try / catch

try {
    flowable.lift(op).subscribe(sub);
} catch (NullPointerException e) {
    if (e.getMessage() != null && e.getMessage().contains("returned a null Subscriber")) {
        // custom operator returned null; fix apply() to return a real Subscriber
        log.error("Custom operator returned null Subscriber: " + op, e);
    }
    throw e;
}

Prevention

When it happens

Trigger: A custom FlowableOperator whose apply() has a code path that returns null (forgotten return, early-out, exception swallowed); an operator that returns null intentionally 'to skip'; a lambda-based operator (FlowableOperator as a lambda) where the lambda body yields null.

Common situations: Hand-written lift operator with incomplete branching; porting an RxJava 2/3 operator that previously returned null in some path; a refactor that deleted the return statement; conditional operators that return null when a feature flag is off.

Related errors


AI-assisted analysis of ReactiveX/RxJava@a8ab535614 (2026-08-13). Data as JSON: /api/errors/056eba6dc9114a7c. Report an issue: GitHub.