diff --git a/conf/nebula-graphd.conf.default b/conf/nebula-graphd.conf.default
index 4f567ee0a4d953f90f02e3567dbb524ca4d8c197..441f714cfa7d09a2f7f8fb1ae9c5c5f33059812a 100644
--- a/conf/nebula-graphd.conf.default
+++ b/conf/nebula-graphd.conf.default
@@ -4,7 +4,7 @@
 # The file to host the process id
 --pid_file=pids/nebula-graphd.pid
 # Whether to enable optimizer
---enable_optimizer=false
+--enable_optimizer=true
 
 ########## logging ##########
 # The directory to host logging files, which must already exists
@@ -24,7 +24,7 @@
 --stderrthreshold=2
 
 ########## query ##########
-# Whether to treat partial success as an error. 
+# Whether to treat partial success as an error.
 # This flag is only used for Read-only access, and Modify access always treats partial success as an error.
 --accept_partial_success=false
 
diff --git a/conf/nebula-graphd.conf.production b/conf/nebula-graphd.conf.production
index 0a5dddb966a082c1c3ab5bab43be224de6e31a0e..89c3f78a22b082ddd2154203da20ff5ff561715e 100644
--- a/conf/nebula-graphd.conf.production
+++ b/conf/nebula-graphd.conf.production
@@ -4,7 +4,7 @@
 # The file to host the process id
 --pid_file=pids/nebula-graphd.pid
 # Whether to enable optimizer
---enable_optimizer=false
+--enable_optimizer=true
 
 ########## logging ##########
 # The directory to host logging files, which must already exists
@@ -24,7 +24,7 @@
 --stderrthreshold=2
 
 ########## query ##########
-# Whether to treat partial success as an error. 
+# Whether to treat partial success as an error.
 # This flag is only used for Read-only access, and Modify access always treats partial success as an error.
 --accept_partial_success=false
 
diff --git a/src/context/CMakeLists.txt b/src/context/CMakeLists.txt
index 71e52540b24d635649dd0bdd7c89b93ea0a63277..9ec166da37f2521ab4613b0ff20b4a7a09c212a7 100644
--- a/src/context/CMakeLists.txt
+++ b/src/context/CMakeLists.txt
@@ -10,6 +10,7 @@ nebula_add_library(
     ExecutionContext.cpp
     Iterator.cpp
     Result.cpp
+    Symbols.cpp
 )
 
 
diff --git a/src/context/Symbols.cpp b/src/context/Symbols.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a6141803ef41832b36fbadd30a07e493fda20a98
--- /dev/null
+++ b/src/context/Symbols.cpp
@@ -0,0 +1,39 @@
+/* Copyright (c) 2021 vesoft inc. All rights reserved.
+ *
+ * This source code is licensed under Apache 2.0 License,
+ * attached with Common Clause Condition 1.0, found in the LICENSES directory.
+ */
+
+#include "context/Symbols.h"
+
+#include <sstream>
+
+#include "planner/PlanNode.h"
+#include "util/Utils.h"
+
+namespace nebula {
+namespace graph {
+
+std::string Variable::toString() const {
+    std::stringstream ss;
+    ss << "name: " << name << ", type: " << type << ", colNames: <" << folly::join(",", colNames)
+       << ">, readBy: <" << util::join(readBy, [](auto pn) { return pn->toString(); })
+       << ">, writtenBy: <" << util::join(writtenBy, [](auto pn) { return pn->toString(); }) << ">";
+    return ss.str();
+}
+
+std::string SymbolTable::toString() const {
+    std::stringstream ss;
+    ss << "SymTable: [";
+    for (const auto& p : vars_) {
+        ss << "\n" << p.first << ": ";
+        if (p.second) {
+            ss << p.second->toString();
+        }
+    }
+    ss << "\n]";
+    return ss.str();
+}
+
+}   // namespace graph
+}   // namespace nebula
diff --git a/src/context/Symbols.h b/src/context/Symbols.h
index 43e78cb797a439c22bc155c3c5db7c50118525d7..4cd6e7f5ae27055af63d69c99cb99aeda810cb3a 100644
--- a/src/context/Symbols.h
+++ b/src/context/Symbols.h
@@ -37,6 +37,7 @@ using ColsDef = std::vector<ColDef>;
 
 struct Variable {
     explicit Variable(std::string n) : name(std::move(n)) {}
+    std::string toString() const;
 
     std::string name;
     Value::Type type{Value::Type::DATASET};
@@ -118,6 +119,8 @@ public:
         }
     }
 
+    std::string toString() const;
+
 private:
     ObjectPool*                                                             objPool_{nullptr};
     // var name -> variable
diff --git a/src/optimizer/CMakeLists.txt b/src/optimizer/CMakeLists.txt
index f175551ec5aedbde06788a0b9d062810d2ee2046..e9aa7cde26b96aab1712a94d94fbeb1c74f28713 100644
--- a/src/optimizer/CMakeLists.txt
+++ b/src/optimizer/CMakeLists.txt
@@ -10,6 +10,7 @@ nebula_add_library(
     Optimizer.cpp
     OptGroup.cpp
     OptRule.cpp
+    OptContext.cpp
     rule/PushFilterDownGetNbrsRule.cpp
     rule/IndexScanRule.cpp
     rule/LimitPushDownRule.cpp
diff --git a/src/optimizer/OptContext.cpp b/src/optimizer/OptContext.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1670a7df206e7848f2e049a68104af382bfdc22d
--- /dev/null
+++ b/src/optimizer/OptContext.cpp
@@ -0,0 +1,28 @@
+/* Copyright (c) 2021 vesoft inc. All rights reserved.
+ *
+ * This source code is licensed under Apache 2.0 License,
+ * attached with Common Clause Condition 1.0, found in the LICENSES directory.
+ */
+
+#include "optimizer/OptContext.h"
+
+#include "common/base/Logging.h"
+#include "common/base/ObjectPool.h"
+
+namespace nebula {
+namespace opt {
+
+OptContext::OptContext(graph::QueryContext *qctx)
+    : qctx_(DCHECK_NOTNULL(qctx)), objPool_(std::make_unique<ObjectPool>()) {}
+
+void OptContext::addPlanNodeAndOptGroupNode(int64_t planNodeId, const OptGroupNode *optGroupNode) {
+    planNodeToOptGroupNodeMap_.emplace(planNodeId, optGroupNode);
+}
+
+const OptGroupNode *OptContext::findOptGroupNodeByPlanNodeId(int64_t planNodeId) const {
+    auto found = planNodeToOptGroupNodeMap_.find(planNodeId);
+    return found == planNodeToOptGroupNodeMap_.end() ? nullptr : found->second;
+}
+
+}   // namespace opt
+}   // namespace nebula
diff --git a/src/optimizer/OptContext.h b/src/optimizer/OptContext.h
new file mode 100644
index 0000000000000000000000000000000000000000..073f1e3ac1c3096e9a936ced8a9f4b234a739be0
--- /dev/null
+++ b/src/optimizer/OptContext.h
@@ -0,0 +1,51 @@
+/* Copyright (c) 2021 vesoft inc. All rights reserved.
+ *
+ * This source code is licensed under Apache 2.0 License,
+ * attached with Common Clause Condition 1.0, found in the LICENSES directory.
+ */
+
+#ifndef OPTIMIZER_OPTCONTEXT_H_
+#define OPTIMIZER_OPTCONTEXT_H_
+
+#include <memory>
+#include <unordered_map>
+
+#include "common/cpp/helpers.h"
+
+namespace nebula {
+
+class ObjectPool;
+
+namespace graph {
+class QueryContext;
+}   // namespace graph
+
+namespace opt {
+
+class OptGroupNode;
+
+class OptContext final : private cpp::NonCopyable, private cpp::NonMovable {
+public:
+    explicit OptContext(graph::QueryContext *qctx);
+
+    graph::QueryContext *qctx() const {
+        return qctx_;
+    }
+
+    ObjectPool *objPool() const {
+        return objPool_.get();
+    }
+
+    void addPlanNodeAndOptGroupNode(int64_t planNodeId, const OptGroupNode *optGroupNode);
+    const OptGroupNode *findOptGroupNodeByPlanNodeId(int64_t planNodeId) const;
+
+private:
+    graph::QueryContext *qctx_{nullptr};
+    std::unique_ptr<ObjectPool> objPool_;
+    std::unordered_map<int64_t, const OptGroupNode *> planNodeToOptGroupNodeMap_;
+};
+
+}   // namespace opt
+}   // namespace nebula
+
+#endif   // OPTIMIZER_OPTCONTEXT_H_
diff --git a/src/optimizer/OptGroup.cpp b/src/optimizer/OptGroup.cpp
index d8c4f6e7503781f6955babdae68b97b9cfc5a806..92364be5dc3cd1ff904b7c9c1b13b952c59231f6 100644
--- a/src/optimizer/OptGroup.cpp
+++ b/src/optimizer/OptGroup.cpp
@@ -9,6 +9,7 @@
 #include <limits>
 
 #include "context/QueryContext.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptRule.h"
 #include "planner/Logic.h"
 #include "planner/PlanNode.h"
@@ -23,12 +24,12 @@ using nebula::graph::SingleDependencyNode;
 namespace nebula {
 namespace opt {
 
-OptGroup *OptGroup::create(QueryContext *qctx) {
-    return qctx->objPool()->add(new OptGroup(qctx));
+OptGroup *OptGroup::create(OptContext *ctx) {
+    return ctx->objPool()->add(new OptGroup(ctx));
 }
 
-OptGroup::OptGroup(QueryContext *qctx) noexcept : qctx_(qctx) {
-    DCHECK(qctx != nullptr);
+OptGroup::OptGroup(OptContext *ctx) noexcept : ctx_(ctx) {
+    DCHECK(ctx != nullptr);
 }
 
 void OptGroup::addGroupNode(OptGroupNode *groupNode) {
@@ -37,8 +38,8 @@ void OptGroup::addGroupNode(OptGroupNode *groupNode) {
     groupNodes_.emplace_back(groupNode);
 }
 
-OptGroupNode *OptGroup::makeGroupNode(QueryContext *qctx, PlanNode *node) {
-    groupNodes_.emplace_back(OptGroupNode::create(qctx, node, this));
+OptGroupNode *OptGroup::makeGroupNode(PlanNode *node) {
+    groupNodes_.emplace_back(OptGroupNode::create(ctx_, node, this));
     return groupNodes_.back();
 }
 
@@ -59,13 +60,13 @@ Status OptGroup::explore(const OptRule *rule) {
         NG_RETURN_IF_ERROR(groupNode->explore(rule));
 
         // Find more equivalents
-        auto status = rule->match(groupNode);
+        auto status = rule->match(ctx_, groupNode);
         if (!status.ok()) {
             ++iter;
             continue;
         }
         auto matched = std::move(status).value();
-        auto resStatus = rule->transform(qctx_, matched);
+        auto resStatus = rule->transform(ctx_, matched);
         NG_RETURN_IF_ERROR(resStatus);
         auto result = std::move(resStatus).value();
         if (result.eraseAll) {
@@ -130,8 +131,10 @@ const PlanNode *OptGroup::getPlan() const {
     return minGroupNode->getPlan();
 }
 
-OptGroupNode *OptGroupNode::create(QueryContext *qctx, PlanNode *node, const OptGroup *group) {
-    return qctx->objPool()->add(new OptGroupNode(node, group));
+OptGroupNode *OptGroupNode::create(OptContext *ctx, PlanNode *node, const OptGroup *group) {
+    auto optGNode = ctx->objPool()->add(new OptGroupNode(node, group));
+    ctx->addPlanNodeAndOptGroupNode(node->id(), optGNode);
+    return optGNode;
 }
 
 OptGroupNode::OptGroupNode(PlanNode *node, const OptGroup *group) noexcept
diff --git a/src/optimizer/OptGroup.h b/src/optimizer/OptGroup.h
index e7a15089a0f9d30b63e40f3d6f3d49de7e24d0a1..de47cad15c529b338d01d43bbb5ea4c12dfc90de 100644
--- a/src/optimizer/OptGroup.h
+++ b/src/optimizer/OptGroup.h
@@ -10,22 +10,23 @@
 #include <algorithm>
 #include <list>
 #include <vector>
+
 #include "common/base/Status.h"
 
 namespace nebula {
 namespace graph {
 class PlanNode;
-class QueryContext;
 }   // namespace graph
 
 namespace opt {
 
+class OptContext;
 class OptGroupNode;
 class OptRule;
 
 class OptGroup final {
 public:
-    static OptGroup *create(graph::QueryContext *qctx);
+    static OptGroup *create(OptContext *ctx);
 
     bool isExplored(const OptRule *rule) const {
         return std::find(exploredRules_.cbegin(), exploredRules_.cend(), rule) !=
@@ -44,7 +45,7 @@ public:
     }
 
     void addGroupNode(OptGroupNode *groupNode);
-    OptGroupNode *makeGroupNode(graph::QueryContext *qctx, graph::PlanNode *node);
+    OptGroupNode *makeGroupNode(graph::PlanNode *node);
     const std::list<OptGroupNode *> &groupNodes() const {
         return groupNodes_;
     }
@@ -55,22 +56,20 @@ public:
     const graph::PlanNode *getPlan() const;
 
 private:
-    explicit OptGroup(graph::QueryContext *qctx) noexcept;
+    explicit OptGroup(OptContext *ctx) noexcept;
 
     static constexpr int16_t kMaxExplorationRound = 128;
 
     std::pair<double, const OptGroupNode *> findMinCostGroupNode() const;
 
-    graph::QueryContext *qctx_{nullptr};
+    OptContext *ctx_{nullptr};
     std::list<OptGroupNode *> groupNodes_;
     std::vector<const OptRule *> exploredRules_;
 };
 
 class OptGroupNode final {
 public:
-    static OptGroupNode *create(graph::QueryContext *qctx,
-                                graph::PlanNode *node,
-                                const OptGroup *group);
+    static OptGroupNode *create(OptContext *ctx, graph::PlanNode *node, const OptGroup *group);
 
     void dependsOn(OptGroup *dep) {
         dependencies_.emplace_back(dep);
diff --git a/src/optimizer/OptRule.cpp b/src/optimizer/OptRule.cpp
index fed3f0e67387c399cdd5024179cff5b3d8aef4da..30d9e834fbd9da7a734768da79391b37786b153d 100644
--- a/src/optimizer/OptRule.cpp
+++ b/src/optimizer/OptRule.cpp
@@ -7,7 +7,10 @@
 #include "optimizer/OptRule.h"
 
 #include "common/base/Logging.h"
+#include "context/Symbols.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptGroup.h"
+#include "planner/PlanNode.h"
 
 namespace nebula {
 namespace opt {
@@ -57,21 +60,58 @@ StatusOr<MatchedResult> Pattern::match(const OptGroup *group) const {
     return Status::Error();
 }
 
-StatusOr<MatchedResult> OptRule::match(const OptGroupNode *groupNode) const {
+StatusOr<MatchedResult> OptRule::match(OptContext *ctx, const OptGroupNode *groupNode) const {
     const auto &pattern = this->pattern();
     auto status = pattern.match(groupNode);
     NG_RETURN_IF_ERROR(status);
     auto matched = std::move(status).value();
-    if (!this->match(matched)) {
+    if (!this->match(ctx, matched)) {
         return Status::Error();
     }
     return matched;
 }
 
-bool OptRule::match(const MatchedResult &matched) const {
-    UNUSED(matched);
-    // Return true if subclass doesn't override this interface,
-    // so optimizer will only check whether pattern is matched
+bool OptRule::match(OptContext *ctx, const MatchedResult &matched) const {
+    return checkDataflowDeps(ctx, matched, matched.node->node()->outputVar(), true);
+}
+
+bool OptRule::checkDataflowDeps(OptContext *ctx,
+                                const MatchedResult &matched,
+                                const std::string &var,
+                                bool isRoot) const {
+    auto node = matched.node;
+    auto planNode = node->node();
+    const auto &outVarName = planNode->outputVar();
+    if (outVarName != var) {
+        return false;
+    }
+    auto symTbl = ctx->qctx()->symTable();
+    auto outVar = symTbl->getVar(outVarName);
+    // Check whether the data flow is same as the control flow in execution plan.
+    if (!isRoot) {
+        for (auto pnode : outVar->readBy) {
+            auto optGNode = ctx->findOptGroupNodeByPlanNodeId(pnode->id());
+            if (!optGNode) continue;
+            const auto &deps = optGNode->dependencies();
+            if (deps.empty()) continue;
+            auto found = std::find(deps.begin(), deps.end(), node->group());
+            if (found == deps.end()) {
+                VLOG(2) << ctx->qctx()->symTable()->toString();
+                return false;
+            }
+        }
+    }
+
+    const auto &deps = matched.dependencies;
+    if (deps.empty()) {
+        return true;
+    }
+    DCHECK_EQ(deps.size(), node->dependencies().size());
+    for (size_t i = 0; i < deps.size(); ++i) {
+        if (!checkDataflowDeps(ctx, deps[i], planNode->inputVar(i), false)) {
+            return false;
+        }
+    }
     return true;
 }
 
@@ -81,7 +121,7 @@ RuleSet &RuleSet::DefaultRules() {
 }
 
 RuleSet &RuleSet::QueryRules() {
-    static RuleSet kQueryRules("QueryRules");
+    static RuleSet kQueryRules("QueryRuleSet");
     return kQueryRules;
 }
 
diff --git a/src/optimizer/OptRule.h b/src/optimizer/OptRule.h
index 9041afead0b72f1a53e5f52affc71137d6768ae4..a9c01a23950c6914179a3edf688e0bb5b40fb0b6 100644
--- a/src/optimizer/OptRule.h
+++ b/src/optimizer/OptRule.h
@@ -16,12 +16,14 @@
 #include "planner/PlanNode.h"
 
 namespace nebula {
+
 namespace graph {
 class QueryContext;
 }   // namespace graph
 
 namespace opt {
 
+class OptContext;
 class OptGroupNode;
 class OptGroup;
 
@@ -57,18 +59,25 @@ public:
         std::vector<OptGroupNode *> newGroupNodes;
     };
 
-    StatusOr<MatchedResult> match(const OptGroupNode *groupNode) const;
+    StatusOr<MatchedResult> match(OptContext *ctx, const OptGroupNode *groupNode) const;
 
     virtual ~OptRule() = default;
 
     virtual const Pattern &pattern() const = 0;
-    virtual bool match(const MatchedResult &matched) const;
-    virtual StatusOr<TransformResult> transform(graph::QueryContext *qctx,
+    virtual bool match(OptContext *ctx, const MatchedResult &matched) const;
+    virtual StatusOr<TransformResult> transform(OptContext *ctx,
                                                 const MatchedResult &matched) const = 0;
     virtual std::string toString() const = 0;
 
 protected:
     OptRule() = default;
+
+    // Return false if the output variable of this matched plan node is not the
+    // input of other plan node
+    bool checkDataflowDeps(OptContext *ctx,
+                           const MatchedResult &matched,
+                           const std::string &var,
+                           bool isRoot) const;
 };
 
 class RuleSet final {
diff --git a/src/optimizer/Optimizer.cpp b/src/optimizer/Optimizer.cpp
index 0c35446e72009c6adc0fc3e1706bcadcf55011d0..2a38db09acc045c3c06d7b8449cc084b9b1219b8 100644
--- a/src/optimizer/Optimizer.cpp
+++ b/src/optimizer/Optimizer.cpp
@@ -7,6 +7,7 @@
 #include "optimizer/Optimizer.h"
 
 #include "context/QueryContext.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptGroup.h"
 #include "optimizer/OptRule.h"
 #include "planner/ExecutionPlan.h"
@@ -27,9 +28,10 @@ Optimizer::Optimizer(std::vector<const RuleSet *> ruleSets) : ruleSets_(std::mov
 
 StatusOr<const PlanNode *> Optimizer::findBestPlan(QueryContext *qctx) {
     DCHECK(qctx != nullptr);
+    auto optCtx = std::make_unique<OptContext>(qctx);
 
     auto root = qctx->plan()->root();
-    auto status = prepare(qctx, root);
+    auto status = prepare(optCtx.get(), root);
     NG_RETURN_IF_ERROR(status);
     auto rootGroup = std::move(status).value();
 
@@ -37,9 +39,9 @@ StatusOr<const PlanNode *> Optimizer::findBestPlan(QueryContext *qctx) {
     return rootGroup->getPlan();
 }
 
-StatusOr<OptGroup *> Optimizer::prepare(QueryContext *qctx, PlanNode *root) {
+StatusOr<OptGroup *> Optimizer::prepare(OptContext *ctx, PlanNode *root) {
     std::unordered_map<int64_t, OptGroup *> visited;
-    return convertToGroup(qctx, root, &visited);
+    return convertToGroup(ctx, root, &visited);
 }
 
 Status Optimizer::doExploration(OptGroup *rootGroup) {
@@ -51,7 +53,7 @@ Status Optimizer::doExploration(OptGroup *rootGroup) {
     return Status::OK();
 }
 
-OptGroup *Optimizer::convertToGroup(QueryContext *qctx,
+OptGroup *Optimizer::convertToGroup(OptContext *ctx,
                                     PlanNode *node,
                                     std::unordered_map<int64_t, OptGroup *> *visited) {
     auto iter = visited->find(node->id());
@@ -59,8 +61,8 @@ OptGroup *Optimizer::convertToGroup(QueryContext *qctx,
         return iter->second;
     }
 
-    auto group = OptGroup::create(qctx);
-    auto groupNode = group->makeGroupNode(qctx, node);
+    auto group = OptGroup::create(ctx);
+    auto groupNode = group->makeGroupNode(node);
 
     switch (node->dependencies().size()) {
         case 0: {
@@ -70,29 +72,29 @@ OptGroup *Optimizer::convertToGroup(QueryContext *qctx,
         case 1: {
             if (node->kind() == PlanNode::Kind::kSelect) {
                 auto select = static_cast<Select *>(node);
-                auto then = convertToGroup(qctx, const_cast<PlanNode *>(select->then()), visited);
+                auto then = convertToGroup(ctx, const_cast<PlanNode *>(select->then()), visited);
                 groupNode->addBody(then);
                 auto otherNode = const_cast<PlanNode *>(select->otherwise());
-                auto otherwise = convertToGroup(qctx, otherNode, visited);
+                auto otherwise = convertToGroup(ctx, otherNode, visited);
                 groupNode->addBody(otherwise);
             } else if (node->kind() == PlanNode::Kind::kLoop) {
                 auto loop = static_cast<Loop *>(node);
-                auto body = convertToGroup(qctx, const_cast<PlanNode *>(loop->body()), visited);
+                auto body = convertToGroup(ctx, const_cast<PlanNode *>(loop->body()), visited);
                 groupNode->addBody(body);
             }
             auto dep = static_cast<SingleDependencyNode *>(node)->dep();
             DCHECK(dep != nullptr);
-            auto depGroup = convertToGroup(qctx, const_cast<graph::PlanNode *>(dep), visited);
+            auto depGroup = convertToGroup(ctx, const_cast<graph::PlanNode *>(dep), visited);
             groupNode->dependsOn(depGroup);
             break;
         }
         case 2: {
             auto bNode = static_cast<BiInputNode *>(node);
             auto leftNode = const_cast<graph::PlanNode *>(bNode->left());
-            auto leftGroup = convertToGroup(qctx, leftNode, visited);
+            auto leftGroup = convertToGroup(ctx, leftNode, visited);
             groupNode->dependsOn(leftGroup);
             auto rightNode = const_cast<graph::PlanNode *>(bNode->right());
-            auto rightGroup = convertToGroup(qctx, rightNode, visited);
+            auto rightGroup = convertToGroup(ctx, rightNode, visited);
             groupNode->dependsOn(rightGroup);
             break;
         }
diff --git a/src/optimizer/Optimizer.h b/src/optimizer/Optimizer.h
index 176a7d86bedf80ba0d8bc75ed411c7f2e01a323f..41294917fd0d99ea4a8df96a15436cd139af3d80 100644
--- a/src/optimizer/Optimizer.h
+++ b/src/optimizer/Optimizer.h
@@ -19,6 +19,7 @@ class QueryContext;
 
 namespace opt {
 
+class OptContext;
 class OptGroup;
 class OptGroupNode;
 class RuleSet;
@@ -31,10 +32,10 @@ public:
     StatusOr<const graph::PlanNode *> findBestPlan(graph::QueryContext *qctx);
 
 private:
-    StatusOr<OptGroup *> prepare(graph::QueryContext *qctx, graph::PlanNode *root);
+    StatusOr<OptGroup *> prepare(OptContext *ctx, graph::PlanNode *root);
     Status doExploration(OptGroup *rootGroup);
 
-    OptGroup *convertToGroup(graph::QueryContext *qctx,
+    OptGroup *convertToGroup(OptContext *ctx,
                              graph::PlanNode *node,
                              std::unordered_map<int64_t, OptGroup *> *visited);
 
diff --git a/src/optimizer/rule/IndexScanRule.cpp b/src/optimizer/rule/IndexScanRule.cpp
index 07cb3d383d5868d2e6a26a68570590ab0f1cee2d..a776c9c66638189d963a1f77c4aea68e6606f857 100644
--- a/src/optimizer/rule/IndexScanRule.cpp
+++ b/src/optimizer/rule/IndexScanRule.cpp
@@ -6,11 +6,13 @@
 
 #include "optimizer/rule/IndexScanRule.h"
 #include "common/expression/LabelAttributeExpression.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptGroup.h"
 #include "planner/PlanNode.h"
 #include "planner/Query.h"
 
 using nebula::graph::IndexScan;
+using nebula::graph::OptimizerUtils;
 
 namespace nebula {
 namespace opt {
@@ -27,7 +29,7 @@ const Pattern& IndexScanRule::pattern() const {
     return pattern;
 }
 
-StatusOr<OptRule::TransformResult> IndexScanRule::transform(graph::QueryContext* qctx,
+StatusOr<OptRule::TransformResult> IndexScanRule::transform(OptContext* ctx,
                                                             const MatchedResult& matched) const {
     auto groupNode = matched.node;
     if (isEmptyResultSet(groupNode)) {
@@ -35,6 +37,7 @@ StatusOr<OptRule::TransformResult> IndexScanRule::transform(graph::QueryContext*
     }
 
     auto filter = filterExpr(groupNode);
+    auto qctx = ctx->qctx();
     IndexQueryCtx iqctx = std::make_unique<std::vector<IndexQueryContext>>();
     if (filter == nullptr) {
         // Only filter is nullptr when lookup on tagname
@@ -48,7 +51,7 @@ StatusOr<OptRule::TransformResult> IndexScanRule::transform(graph::QueryContext*
 
     auto newIN = static_cast<const IndexScan*>(groupNode->node())->clone(qctx);
     newIN->setIndexQueryContext(std::move(iqctx));
-    auto newGroupNode = OptGroupNode::create(qctx, newIN, groupNode->group());
+    auto newGroupNode = OptGroupNode::create(ctx, newIN, groupNode->group());
     if (groupNode->dependencies().size() != 1) {
         return Status::Error("Plan node dependencies error");
     }
diff --git a/src/optimizer/rule/IndexScanRule.h b/src/optimizer/rule/IndexScanRule.h
index 76f91776acab066508215b907d36d0d5dbf238a6..697dcf1bc545d4ea44660400cd81094f5723c5d0 100644
--- a/src/optimizer/rule/IndexScanRule.h
+++ b/src/optimizer/rule/IndexScanRule.h
@@ -11,14 +11,8 @@
 #include "optimizer/OptimizerUtils.h"
 
 namespace nebula {
-namespace graph {
-class IndexScan;
-}   // namespace graph
 namespace opt {
 
-using graph::IndexScan;
-using graph::OptimizerUtils;
-using graph::PlanNode;
 using graph::QueryContext;
 using storage::cpp2::IndexQueryContext;
 using storage::cpp2::IndexColumnHint;
@@ -26,6 +20,8 @@ using BVO = graph::OptimizerUtils::BoundValueOperator;
 using IndexItem = std::shared_ptr<meta::cpp2::IndexItem>;
 using IndexQueryCtx = std::unique_ptr<std::vector<IndexQueryContext>>;
 
+class OptContext;
+
 class IndexScanRule final : public OptRule {
     FRIEND_TEST(IndexScanRuleTest, BoundValueTest);
     FRIEND_TEST(IndexScanRuleTest, IQCtxTest);
@@ -34,7 +30,7 @@ class IndexScanRule final : public OptRule {
 public:
     const Pattern& pattern() const override;
 
-    StatusOr<TransformResult> transform(graph::QueryContext* qctx,
+    StatusOr<TransformResult> transform(OptContext* ctx,
                                         const MatchedResult& matched) const override;
 
     std::string toString() const override;
diff --git a/src/optimizer/rule/LimitPushDownRule.cpp b/src/optimizer/rule/LimitPushDownRule.cpp
index 2ed688806f09a939c3b5fb5e8d1c6d54786db167..313069fc8534c1e7a6b1f946e9ca0baa9e3e3d41 100644
--- a/src/optimizer/rule/LimitPushDownRule.cpp
+++ b/src/optimizer/rule/LimitPushDownRule.cpp
@@ -12,6 +12,7 @@
 #include "common/expression/FunctionCallExpression.h"
 #include "common/expression/LogicalExpression.h"
 #include "common/expression/UnaryExpression.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptGroup.h"
 #include "planner/PlanNode.h"
 #include "planner/Query.h"
@@ -42,7 +43,7 @@ const Pattern &LimitPushDownRule::pattern() const {
 }
 
 StatusOr<OptRule::TransformResult> LimitPushDownRule::transform(
-    QueryContext *qctx,
+    OptContext *ctx,
     const MatchedResult &matched) const {
     auto limitGroupNode = matched.node;
     auto projGroupNode = matched.dependencies.front().node;
@@ -57,17 +58,18 @@ StatusOr<OptRule::TransformResult> LimitPushDownRule::transform(
         return TransformResult::noTransform();
     }
 
+    auto qctx = ctx->qctx();
     auto newLimit = limit->clone(qctx);
-    auto newLimitGroupNode = OptGroupNode::create(qctx, newLimit, limitGroupNode->group());
+    auto newLimitGroupNode = OptGroupNode::create(ctx, newLimit, limitGroupNode->group());
 
     auto newProj = proj->clone(qctx);
-    auto newProjGroup = OptGroup::create(qctx);
-    auto newProjGroupNode = newProjGroup->makeGroupNode(qctx, newProj);
+    auto newProjGroup = OptGroup::create(ctx);
+    auto newProjGroupNode = newProjGroup->makeGroupNode(newProj);
 
     auto newGn = gn->clone(qctx);
     newGn->setLimit(limitRows);
-    auto newGnGroup = OptGroup::create(qctx);
-    auto newGnGroupNode = newGnGroup->makeGroupNode(qctx, newGn);
+    auto newGnGroup = OptGroup::create(ctx);
+    auto newGnGroupNode = newGnGroup->makeGroupNode(newGn);
 
     newLimitGroupNode->dependsOn(newProjGroup);
     newProjGroupNode->dependsOn(newGnGroup);
diff --git a/src/optimizer/rule/LimitPushDownRule.h b/src/optimizer/rule/LimitPushDownRule.h
index 90b134b60542709f0091a64f709a5effa36aad44..8414b0b2a43a5238d08d7695e284d3c89a853030 100644
--- a/src/optimizer/rule/LimitPushDownRule.h
+++ b/src/optimizer/rule/LimitPushDownRule.h
@@ -12,19 +12,13 @@
 #include "optimizer/OptRule.h"
 
 namespace nebula {
-namespace graph {
-class Limit;
-class Project;
-class GetNeighbors;
-}   // namespace graph
-
 namespace opt {
 
 class LimitPushDownRule final : public OptRule {
 public:
     const Pattern &pattern() const override;
 
-    StatusOr<OptRule::TransformResult> transform(graph::QueryContext *qctx,
+    StatusOr<OptRule::TransformResult> transform(OptContext *ctx,
                                                  const MatchedResult &matched) const override;
 
     std::string toString() const override;
diff --git a/src/optimizer/rule/PushFilterDownGetNbrsRule.cpp b/src/optimizer/rule/PushFilterDownGetNbrsRule.cpp
index 8218afe600dfaace63705b66a95802c8902ea17f..3623b365679a8bebfee53dc0303be978de9a1678 100644
--- a/src/optimizer/rule/PushFilterDownGetNbrsRule.cpp
+++ b/src/optimizer/rule/PushFilterDownGetNbrsRule.cpp
@@ -12,6 +12,7 @@
 #include "common/expression/FunctionCallExpression.h"
 #include "common/expression/LogicalExpression.h"
 #include "common/expression/UnaryExpression.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptGroup.h"
 #include "planner/PlanNode.h"
 #include "planner/Query.h"
@@ -39,7 +40,7 @@ const Pattern &PushFilterDownGetNbrsRule::pattern() const {
 }
 
 StatusOr<OptRule::TransformResult> PushFilterDownGetNbrsRule::transform(
-    QueryContext *qctx,
+    OptContext *ctx,
     const MatchedResult &matched) const {
     auto filterGroupNode = matched.node;
     auto gnGroupNode = matched.dependencies.front().node;
@@ -53,6 +54,7 @@ StatusOr<OptRule::TransformResult> PushFilterDownGetNbrsRule::transform(
         return TransformResult::noTransform();
     }
 
+    auto qctx = ctx->qctx();
     auto pool = qctx->objPool();
     auto remainedExpr = std::move(visitor).remainedExpr();
     OptGroupNode *newFilterGroupNode = nullptr;
@@ -60,7 +62,7 @@ StatusOr<OptRule::TransformResult> PushFilterDownGetNbrsRule::transform(
         auto newFilter = Filter::make(qctx, nullptr, pool->add(remainedExpr.release()));
         newFilter->setOutputVar(filter->outputVar());
         newFilter->setInputVar(filter->inputVar());
-        newFilterGroupNode = OptGroupNode::create(qctx, newFilter, filterGroupNode->group());
+        newFilterGroupNode = OptGroupNode::create(ctx, newFilter, filterGroupNode->group());
     }
 
     auto newGNFilter = condition->encode();
@@ -77,12 +79,12 @@ StatusOr<OptRule::TransformResult> PushFilterDownGetNbrsRule::transform(
     OptGroupNode *newGnGroupNode = nullptr;
     if (newFilterGroupNode != nullptr) {
         // Filter(A&&B)<-GetNeighbors(C) => Filter(A)<-GetNeighbors(B&&C)
-        auto newGroup = OptGroup::create(qctx);
-        newGnGroupNode = newGroup->makeGroupNode(qctx, newGN);
+        auto newGroup = OptGroup::create(ctx);
+        newGnGroupNode = newGroup->makeGroupNode(newGN);
         newFilterGroupNode->dependsOn(newGroup);
     } else {
         // Filter(A)<-GetNeighbors(C) => GetNeighbors(A&&C)
-        newGnGroupNode = OptGroupNode::create(qctx, newGN, filterGroupNode->group());
+        newGnGroupNode = OptGroupNode::create(ctx, newGN, filterGroupNode->group());
         newGN->setOutputVar(filter->outputVar());
     }
 
diff --git a/src/optimizer/rule/PushFilterDownGetNbrsRule.h b/src/optimizer/rule/PushFilterDownGetNbrsRule.h
index 4cabc0677e21e50d939bd82c125fcf5f80620ac4..08384d4805facce1985473c50f3e5772f5af4567 100644
--- a/src/optimizer/rule/PushFilterDownGetNbrsRule.h
+++ b/src/optimizer/rule/PushFilterDownGetNbrsRule.h
@@ -12,17 +12,13 @@
 #include "optimizer/OptRule.h"
 
 namespace nebula {
-namespace graph {
-class GetNeighbors;
-}   // namespace graph
-
 namespace opt {
 
 class PushFilterDownGetNbrsRule final : public OptRule {
 public:
     const Pattern &pattern() const override;
 
-    StatusOr<TransformResult> transform(graph::QueryContext *qctx,
+    StatusOr<TransformResult> transform(OptContext *ctx,
                                         const MatchedResult &matched) const override;
 
     std::string toString() const override;
diff --git a/src/optimizer/rule/TopNRule.cpp b/src/optimizer/rule/TopNRule.cpp
index b7886b0bd958e4d28abc46477a1ac39e0f775991..e10318fffd0cff13c306efcaf335d7d32245df79 100644
--- a/src/optimizer/rule/TopNRule.cpp
+++ b/src/optimizer/rule/TopNRule.cpp
@@ -12,6 +12,7 @@
 #include "common/expression/FunctionCallExpression.h"
 #include "common/expression/LogicalExpression.h"
 #include "common/expression/UnaryExpression.h"
+#include "optimizer/OptContext.h"
 #include "optimizer/OptGroup.h"
 #include "planner/PlanNode.h"
 #include "planner/Query.h"
@@ -38,7 +39,7 @@ const Pattern &TopNRule::pattern() const {
     return pattern;
 }
 
-StatusOr<OptRule::TransformResult> TopNRule::transform(QueryContext *qctx,
+StatusOr<OptRule::TransformResult> TopNRule::transform(OptContext *ctx,
                                                        const MatchedResult &matched) const {
     auto limitGroupNode = matched.node;
     auto sortGroupNode = matched.dependencies.front().node;
@@ -51,11 +52,12 @@ StatusOr<OptRule::TransformResult> TopNRule::transform(QueryContext *qctx,
         return TransformResult::noTransform();
     }
 
+    auto qctx = ctx->qctx();
     auto topn = TopN::make(qctx, nullptr, sort->factors(), limit->offset(), limit->count());
     topn->setOutputVar(limit->outputVar());
     topn->setInputVar(sort->inputVar());
     topn->setColNames(sort->colNames());
-    auto topnNode = OptGroupNode::create(qctx, topn, limitGroupNode->group());
+    auto topnNode = OptGroupNode::create(ctx, topn, limitGroupNode->group());
     for (auto dep : sortGroupNode->dependencies()) {
         topnNode->dependsOn(dep);
     }
diff --git a/src/optimizer/rule/TopNRule.h b/src/optimizer/rule/TopNRule.h
index f6114f23e7f721013a47d8943e4fc89653831aa7..ef34d0ec9c8155f7d59f7f07c1b8ebe3f3edfbb6 100644
--- a/src/optimizer/rule/TopNRule.h
+++ b/src/optimizer/rule/TopNRule.h
@@ -18,7 +18,7 @@ class TopNRule final : public OptRule {
 public:
     const Pattern &pattern() const override;
 
-    StatusOr<OptRule::TransformResult> transform(graph::QueryContext *qctx,
+    StatusOr<OptRule::TransformResult> transform(OptContext *ctx,
                                                  const MatchedResult &matched) const override;
 
     std::string toString() const override;
diff --git a/src/optimizer/test/IndexScanRuleTest.cpp b/src/optimizer/test/IndexScanRuleTest.cpp
index b0ced7f6d3df2603a52f342eb6bd7ce73c7ac569..b025072a6b5c861d7b088796944b7d589523b892 100644
--- a/src/optimizer/test/IndexScanRuleTest.cpp
+++ b/src/optimizer/test/IndexScanRuleTest.cpp
@@ -8,6 +8,8 @@
 #include "optimizer/OptimizerUtils.h"
 #include "optimizer/rule/IndexScanRule.h"
 
+using nebula::graph::OptimizerUtils;
+
 namespace nebula {
 namespace opt {
 
diff --git a/src/planner/PlanNode.cpp b/src/planner/PlanNode.cpp
index e2d45176438c5d7deb86cee4517efdf6c3b4dd98..edd18cb7696319506f424d6ad069ac6c8369b4e2 100644
--- a/src/planner/PlanNode.cpp
+++ b/src/planner/PlanNode.cpp
@@ -9,6 +9,7 @@
 #include <memory>
 #include <vector>
 
+#include <folly/String.h>
 #include <folly/json.h>
 
 #include "common/graph/Response.h"
@@ -263,6 +264,10 @@ const char* PlanNode::toString(PlanNode::Kind kind) {
     LOG(FATAL) << "Impossible kind plan node " << static_cast<int>(kind);
 }
 
+std::string PlanNode::toString() const {
+    return folly::stringPrintf("%s_%ld", toString(kind_), id_);
+}
+
 // static
 void PlanNode::addDescription(std::string key, std::string value, PlanNodeDescription* desc) {
     if (desc->description == nullptr) {
@@ -275,6 +280,28 @@ void PlanNode::calcCost() {
     VLOG(1) << "unimplemented cost calculation.";
 }
 
+void PlanNode::setOutputVar(const std::string& var) {
+    DCHECK_EQ(1, outputVars_.size());
+    auto* outputVarPtr = qctx_->symTable()->getVar(var);
+    DCHECK(outputVarPtr != nullptr);
+    auto oldVar = outputVars_[0]->name;
+    outputVars_[0] = outputVarPtr;
+    qctx_->symTable()->updateWrittenBy(oldVar, var, this);
+}
+
+void PlanNode::setInputVar(const std::string& varname, size_t idx) {
+    std::string oldVar = inputVar(idx);
+    auto symTable = qctx_->symTable();
+    auto varPtr = symTable->getVar(varname);
+    DCHECK(varPtr != nullptr);
+    inputVars_[idx] = varPtr;
+    if (!oldVar.empty()) {
+        symTable->updateReadBy(oldVar, varname, this);
+    } else {
+        symTable->readBy(varname, this);
+    }
+}
+
 std::unique_ptr<PlanNodeDescription> PlanNode::explain() const {
     auto desc = std::make_unique<PlanNodeDescription>();
     desc->id = id_;
diff --git a/src/planner/PlanNode.h b/src/planner/PlanNode.h
index 47217724c627fc02e058e1ce3f9b18c5ddc5b413..3645c05473a8ef14569034fdc6af7ec8af2b7039 100644
--- a/src/planner/PlanNode.h
+++ b/src/planner/PlanNode.h
@@ -164,14 +164,7 @@ public:
         return false;
     }
 
-    void setOutputVar(const std::string &var) {
-        DCHECK_EQ(1, outputVars_.size());
-        auto* outputVarPtr = qctx_->symTable()->getVar(var);
-        DCHECK(outputVarPtr != nullptr);
-        auto oldVar = outputVars_[0]->name;
-        outputVars_[0] = outputVarPtr;
-        qctx_->symTable()->updateWrittenBy(oldVar, var, this);
-    }
+    void setOutputVar(const std::string &var);
 
     std::string outputVar(size_t index = 0) const {
         DCHECK_LT(index, outputVars_.size());
@@ -225,7 +218,19 @@ public:
         return dependencies_;
     }
 
+    std::string inputVar(size_t idx = 0UL) const {
+        DCHECK_LT(idx, inputVars_.size());
+        return inputVars_[idx] ? inputVars_[idx]->name : "";
+    }
+
+    void setInputVar(const std::string& varname, size_t idx = 0UL);
+
+    const std::vector<Variable*>& inputVars() const {
+        return inputVars_;
+    }
+
     static const char* toString(Kind kind);
+    std::string toString() const;
 
     double cost() const {
         return cost_;
@@ -280,31 +285,6 @@ public:
         return true;
     }
 
-    void setInputVar(std::string inputVar) {
-        DCHECK(!inputVars_.empty());
-        auto* inputVarPtr = qctx_->symTable()->getVar(inputVar);
-        DCHECK(inputVarPtr != nullptr);
-        std::string oldVar;
-        if (inputVars_[0] != nullptr) {
-            oldVar = inputVars_[0]->name;
-        }
-        inputVars_[0] = inputVarPtr;
-        if (!oldVar.empty()) {
-            qctx_->symTable()->updateReadBy(oldVar, inputVar, this);
-        } else {
-            qctx_->symTable()->readBy(inputVar, this);
-        }
-    }
-
-    std::string inputVar() const {
-        DCHECK(!inputVars_.empty());
-        if (inputVars_[0] != nullptr) {
-            return inputVars_[0]->name;
-        } else {
-            return "";
-        }
-    }
-
     std::unique_ptr<PlanNodeDescription> explain() const override;
 
 protected:
@@ -334,36 +314,12 @@ public:
         setDep(1, right);
     }
 
-    void setLeftVar(std::string leftVar) {
-        DCHECK_GE(inputVars_.size(), 1);
-        auto* leftVarPtr = qctx_->symTable()->getVar(leftVar);
-        DCHECK(leftVarPtr != nullptr);
-        std::string oldVar;
-        if (inputVars_[0] != nullptr) {
-            oldVar = inputVars_[0]->name;
-        }
-        inputVars_[0] = leftVarPtr;
-        if (!oldVar.empty()) {
-            qctx_->symTable()->updateReadBy(oldVar, leftVar, this);
-        } else {
-            qctx_->symTable()->readBy(leftVar, this);
-        }
+    void setLeftVar(const std::string& leftVar) {
+        setInputVar(leftVar, 0);
     }
 
-    void setRightVar(std::string rightVar) {
-        DCHECK_EQ(inputVars_.size(), 2);
-        auto* rightVarPtr = qctx_->symTable()->getVar(rightVar);
-        DCHECK(rightVarPtr != nullptr);
-        std::string oldVar;
-        if (inputVars_[1] != nullptr) {
-            oldVar = inputVars_[1]->name;
-        }
-        inputVars_[1] = rightVarPtr;
-        if (!oldVar.empty()) {
-            qctx_->symTable()->updateReadBy(oldVar, rightVar, this);
-        } else {
-            qctx_->symTable()->readBy(rightVar, this);
-        }
+    void setRightVar(const std::string& rightVar) {
+        setInputVar(rightVar, 1);
     }
 
     const PlanNode* left() const {
diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt
index 9050a007b1d08d6d57c13fe7673a26df46b8738a..0cb2e6c6640445b7af4d9460954e98990c46f12b 100644
--- a/src/util/CMakeLists.txt
+++ b/src/util/CMakeLists.txt
@@ -10,8 +10,6 @@ nebula_add_library(
     SchemaUtil.cpp
     IndexUtil.cpp
     ZoneUtil.cpp
-    
-    GroupUtil.cpp
     ToJson.cpp
 )
 
diff --git a/src/util/GroupUtil.cpp b/src/util/GroupUtil.cpp
deleted file mode 100644
index c3f9ded9f8145f3725a62a45299d3b0d27a363cf..0000000000000000000000000000000000000000
--- a/src/util/GroupUtil.cpp
+++ /dev/null
@@ -1,14 +0,0 @@
-/* Copyright (c) 2020 vesoft inc. All rights reserved.
- *
- * This source code is licensed under Apache 2.0 License,
- * attached with Common Clause Condition 1.0, found in the LICENSES directory.
- */
-
-#include "util/GroupUtil.h"
-
-namespace nebula {
-namespace graph {
-
-
-}   // namespace graph
-}   // namespace nebula
diff --git a/src/util/GroupUtil.h b/src/util/GroupUtil.h
deleted file mode 100644
index 8b815ab7071d2109be6e4d58f57e52f793adf999..0000000000000000000000000000000000000000
--- a/src/util/GroupUtil.h
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright (c) 2020 vesoft inc. All rights reserved.
- *
- * This source code is licensed under Apache 2.0 License,
- * attached with Common Clause Condition 1.0, found in the LICENSES directory.
- */
-
-#ifndef UTIL_GROUPUTIL_H_
-#define UTIL_GROUPUTIL_H_
-
-#include "common/base/Base.h"
-#include "common/base/StatusOr.h"
-#include "parser/MaintainSentences.h"
-
-namespace nebula {
-namespace graph {
-
-class GroupUtil final {
-public:
-    GroupUtil() = delete;
-};
-
-}  // namespace graph
-}  // namespace nebula
-#endif  // UTIL_GROUPUTIL_H_
diff --git a/src/util/Utils.h b/src/util/Utils.h
new file mode 100644
index 0000000000000000000000000000000000000000..28ec452cbe57c3368ded04520b2769ffaeae4c7e
--- /dev/null
+++ b/src/util/Utils.h
@@ -0,0 +1,31 @@
+/* Copyright (c) 2021 vesoft inc. All rights reserved.
+ *
+ * This source code is licensed under Apache 2.0 License,
+ * attached with Common Clause Condition 1.0, found in the LICENSES directory.
+ */
+
+#ifndef UTIL_UTILS_H_
+#define UTIL_UTILS_H_
+
+#include <iterator>
+#include <string>
+#include <vector>
+
+#include <folly/String.h>
+
+namespace nebula {
+namespace util {
+
+template <typename Container, typename Fn>
+std::string join(const Container& container, Fn fn, const std::string& delimiter = ",") {
+    std::vector<std::string> strs;
+    for (auto iter = std::begin(container), end = std::end(container); iter != end; ++iter) {
+        strs.emplace_back(fn(*iter));
+    }
+    return folly::join(delimiter, strs);
+}
+
+}   // namespace util
+}   // namespace nebula
+
+#endif   // UTIL_UTILS_H_
diff --git a/src/validator/test/ValidatorTestBase.cpp b/src/validator/test/ValidatorTestBase.cpp
index d67d2bd1ace71325afffb38d5727d197f62a7b03..8706529db7348d21ab6b4ab758537e3b7b4fc265 100644
--- a/src/validator/test/ValidatorTestBase.cpp
+++ b/src/validator/test/ValidatorTestBase.cpp
@@ -16,6 +16,7 @@
 #include "planner/Mutate.h"
 #include "planner/PlanNode.h"
 #include "planner/Query.h"
+#include "util/Utils.h"
 
 namespace nebula {
 namespace graph {
@@ -248,9 +249,8 @@ Status ValidatorTestBase::EqSelf(const PlanNode *l, const PlanNode *r) {
 }
 
 std::ostream& operator<<(std::ostream& os, const std::vector<PlanNode::Kind>& plan) {
-    std::vector<const char*> kinds(plan.size());
-    std::transform(plan.cbegin(), plan.cend(), kinds.begin(), PlanNode::toString);
-    os << "[" << folly::join(", ", kinds) << "]";
+    auto printPNKind = [](auto k) { return PlanNode::toString(k); };
+    os << "[" << util::join(plan, printPNKind, ", ") << "]";
     return os;
 }