diff --git a/src/backend/executor/nodeHashjoin.c b/src/backend/executor/nodeHashjoin.c
index 202dd866251..5df351ac5b6 100644
--- a/src/backend/executor/nodeHashjoin.c
+++ b/src/backend/executor/nodeHashjoin.c
@@ -195,10 +195,12 @@
 
 static TupleTableSlot *ExecHashJoinOuterGetTuple(PlanState *outerNode,
 												 HashJoinState *hjstate,
-												 uint32 *hashvalue);
+												 uint32 *hashvalue,
+												 bool *is_null);
 static TupleTableSlot *ExecParallelHashJoinOuterGetTuple(PlanState *outerNode,
 														 HashJoinState *hjstate,
-														 uint32 *hashvalue);
+														 uint32 *hashvalue,
+														 bool *is_null);
 static TupleTableSlot *ExecHashJoinGetSavedTuple(HashJoinState *hjstate,
 												 BufFile *file,
 												 uint32 *hashvalue,
@@ -233,6 +235,7 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 	HashJoinTable hashtable;
 	TupleTableSlot *outerTupleSlot;
 	uint32		hashvalue;
+	bool		is_null;
 	int			batchno;
 	ParallelHashJoinState *parallel_state;
 
@@ -440,10 +443,13 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 				if (parallel)
 					outerTupleSlot =
 						ExecParallelHashJoinOuterGetTuple(outerNode, node,
-														  &hashvalue);
+														  &hashvalue,
+														  &is_null);
 				else
 					outerTupleSlot =
-						ExecHashJoinOuterGetTuple(outerNode, node, &hashvalue);
+						ExecHashJoinOuterGetTuple(outerNode, node,
+												  &hashvalue,
+												  &is_null);
 
 				if (TupIsNull(outerTupleSlot))
 				{
@@ -489,6 +495,16 @@ ExecHashJoinImpl(PlanState *pstate, bool parallel)
 				econtext->ecxt_outertuple = outerTupleSlot;
 				node->hj_MatchedOuter = false;
 
+				/*
+				 * If the tuple can't match anything, skip all the tedium and
+				 * go directly to emitting it in null-extended form.
+				 */
+				if (is_null)
+				{
+					node->hj_JoinState = HJ_FILL_OUTER_TUPLE;
+					continue;
+				}
+
 				/*
 				 * Find the corresponding bucket for this tuple in the main
 				 * hash table or skew hash table.
@@ -1104,12 +1120,15 @@ ExecEndHashJoin(HashJoinState *node)
  * Returns a null slot if no more outer tuples (within the current batch).
  *
  * On success, the tuple's hash value is stored at *hashvalue --- this is
- * either originally computed, or re-read from the temp file.
+ * either originally computed, or re-read from the temp file.  Also, we
+ * set *is_null to true if the tuple cannot have any matches due to
+ * containing a null join key (in which case the hashvalue is meaningless).
  */
 static TupleTableSlot *
 ExecHashJoinOuterGetTuple(PlanState *outerNode,
 						  HashJoinState *hjstate,
-						  uint32 *hashvalue)
+						  uint32 *hashvalue,
+						  bool *is_null)
 {
 	HashJoinTable hashtable = hjstate->hj_HashTable;
 	int			curbatch = hashtable->curbatch;
@@ -1129,8 +1148,6 @@ ExecHashJoinOuterGetTuple(PlanState *outerNode,
 
 		while (!TupIsNull(slot))
 		{
-			bool		isnull;
-
 			/*
 			 * We have to compute the tuple's hash value.
 			 */
@@ -1142,9 +1159,9 @@ ExecHashJoinOuterGetTuple(PlanState *outerNode,
 
 			*hashvalue = DatumGetUInt32(ExecEvalExprSwitchContext(hjstate->hj_OuterHash,
 																  econtext,
-																  &isnull));
+																  is_null));
 
-			if (!isnull)
+			if (!*is_null)
 			{
 				/* normal case with a non-null join key */
 				/* remember outer relation is not empty for possible rescan */
@@ -1154,10 +1171,8 @@ ExecHashJoinOuterGetTuple(PlanState *outerNode,
 			}
 			else if (hjstate->hj_KeepNullTuples)
 			{
-				/* null join key, but we must save tuple to be emitted later */
-				if (hjstate->hj_NullOuterTupleStore == NULL)
-					hjstate->hj_NullOuterTupleStore = ExecHashBuildNullTupleStore(hashtable);
-				tuplestore_puttupleslot(hjstate->hj_NullOuterTupleStore, slot);
+				/* null join key, but we must return tuple to be emitted */
+				return slot;
 			}
 
 			/*
@@ -1183,7 +1198,10 @@ ExecHashJoinOuterGetTuple(PlanState *outerNode,
 										 hashvalue,
 										 hjstate->hj_OuterTupleSlot);
 		if (!TupIsNull(slot))
+		{
+			*is_null = false;	/* else it'd not be in the batch file */
 			return slot;
+		}
 	}
 
 	/* End of this batch */
@@ -1196,7 +1214,8 @@ ExecHashJoinOuterGetTuple(PlanState *outerNode,
 static TupleTableSlot *
 ExecParallelHashJoinOuterGetTuple(PlanState *outerNode,
 								  HashJoinState *hjstate,
-								  uint32 *hashvalue)
+								  uint32 *hashvalue,
+								  bool *is_null)
 {
 	HashJoinTable hashtable = hjstate->hj_HashTable;
 	int			curbatch = hashtable->curbatch;
@@ -1213,8 +1232,6 @@ ExecParallelHashJoinOuterGetTuple(PlanState *outerNode,
 
 		while (!TupIsNull(slot))
 		{
-			bool		isnull;
-
 			ExprContext *econtext = hjstate->js.ps.ps_ExprContext;
 
 			econtext->ecxt_outertuple = slot;
@@ -1223,19 +1240,17 @@ ExecParallelHashJoinOuterGetTuple(PlanState *outerNode,
 
 			*hashvalue = DatumGetUInt32(ExecEvalExprSwitchContext(hjstate->hj_OuterHash,
 																  econtext,
-																  &isnull));
+																  is_null));
 
-			if (!isnull)
+			if (!*is_null)
 			{
 				/* normal case with a non-null join key */
 				return slot;
 			}
 			else if (hjstate->hj_KeepNullTuples)
 			{
-				/* null join key, but we must save tuple to be emitted later */
-				if (hjstate->hj_NullOuterTupleStore == NULL)
-					hjstate->hj_NullOuterTupleStore = ExecHashBuildNullTupleStore(hashtable);
-				tuplestore_puttupleslot(hjstate->hj_NullOuterTupleStore, slot);
+				/* null join key, but we must return tuple to be emitted */
+				return slot;
 			}
 
 			/*
@@ -1256,6 +1271,7 @@ ExecParallelHashJoinOuterGetTuple(PlanState *outerNode,
 			ExecForceStoreMinimalTuple(tuple,
 									   hjstate->hj_OuterTupleSlot,
 									   false);
+			*is_null = false;	/* else it'd not be in the batch file */
 			slot = hjstate->hj_OuterTupleSlot;
 			return slot;
 		}
diff --git a/src/include/executor/hashjoin.h b/src/include/executor/hashjoin.h
index 4d342174b9a..e77971ed0d1 100644
--- a/src/include/executor/hashjoin.h
+++ b/src/include/executor/hashjoin.h
@@ -74,11 +74,12 @@
  * If an input tuple has a null join key, then it cannot match anything from
  * the other side of the join.  Normally we can just discard such a tuple
  * immediately, but if it comes from the outer side of an outer join then we
- * must emit it with null-extension of the other side.  For various reasons
- * it's not convenient to do that immediately on seeing the tuple, so we dump
- * the tuple into a tuplestore and emit it later.  (In the unlikely but
- * supported case of a non-strict join operator, we treat null keys as normal
- * data.)
+ * must emit it with null-extension of the other side.  For an outer tuple
+ * it's possible to emit it immediately and then discard it.  But for an inner
+ * tuple, or an outer tuple of a multi-batch parallel join, that is not
+ * convenient, so we dump the tuple into a tuplestore and emit it later.
+ * (In the unlikely but supported case of a non-strict join operator, we treat
+ * null keys as normal data.)
  * ----------------------------------------------------------------
  */
 
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e3cea52e7df..fe8681ba642 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -2203,7 +2203,8 @@ typedef struct MergeJoinState
  *								outer joins
  *		hj_NullInnerTupleSlot	prepared null tuple for left/full outer joins
  *		hj_NullOuterTupleStore	tuplestore holding outer tuples that have
- *								null join keys (but must be emitted anyway)
+ *								null join keys (but must be emitted anyway);
+ *								used only in multi-batch parallel joins
  *		hj_FirstOuterTupleSlot	first tuple retrieved from outer plan
  *		hj_JoinState			current state of ExecHashJoin state machine
  *		hj_KeepNullTuples		true to keep outer tuples with null join keys
