You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iotdb.apache.org by ja...@apache.org on 2026/04/02 10:45:29 UTC

(iotdb) 01/06: First step by opus-4.6

This is an automated email from the ASF dual-hosted git repository.

jackietien pushed a commit to branch ty/explain_format
in repository https://gitbox.apache.org/repos/asf/iotdb.git

commit e2e91d5a22eb3e37cc7bc189b80135fb9beae0cb
Author: JackieTien97 <ja...@gmail.com>
AuthorDate: Wed Apr 1 19:20:48 2026 +0800

    First step by opus-4.6
---
 .../it/query/recent/IoTExplainJsonFormatIT.java    | 251 ++++++++++++++
 .../db/queryengine/common/MPPQueryContext.java     |  10 +
 .../execution/operator/ExplainAnalyzeOperator.java |  37 ++
 .../TableModelStatementMemorySourceVisitor.java    |  65 +++-
 .../plan/planner/TableOperatorGenerator.java       |   7 +-
 .../planner/plan/node/PlanGraphJsonPrinter.java    | 220 ++++++++++++
 .../relational/analyzer/StatementAnalyzer.java     |   2 +
 .../relational/planner/TableLogicalPlanner.java    |   3 +-
 .../planner/node/ExplainAnalyzeNode.java           |  39 ++-
 .../plan/relational/sql/ast/Explain.java           |  22 +-
 .../plan/relational/sql/ast/ExplainAnalyze.java    |  24 +-
 .../relational/sql/ast/ExplainOutputFormat.java    |  26 ++
 .../plan/relational/sql/parser/AstBuilder.java     |  35 +-
 .../FragmentInstanceStatisticsJsonDrawer.java      | 371 +++++++++++++++++++++
 .../planner/node/PlanGraphJsonPrinterTest.java     | 120 +++++++
 .../plan/relational/sql/ExplainFormatTest.java     | 127 +++++++
 .../db/relational/grammar/sql/RelationalSql.g4     |   4 +-
 17 files changed, 1345 insertions(+), 18 deletions(-)

diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java
new file mode 100644
index 00000000000..94ed692ab64
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java
@@ -0,0 +1,251 @@
+/*
+ *
+ *  * Licensed to the Apache Software Foundation (ASF) under one
+ *  * or more contributor license agreements.  See the NOTICE file
+ *  * distributed with this work for additional information
+ *  * regarding copyright ownership.  The ASF licenses this file
+ *  * to you under the Apache License, Version 2.0 (the
+ *  * "License"); you may not use this file except in compliance
+ *  * with the License.  You may obtain a copy of the License at
+ *  *
+ *  *     http://www.apache.org/licenses/LICENSE-2.0
+ *  *
+ *  * Unless required by applicable law or agreed to in writing,
+ *  * software distributed under the License is distributed on an
+ *  * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  * KIND, either express or implied.  See the License for the
+ *  * specific language governing permissions and limitations
+ *  * under the License.
+ *
+ */
+
+package org.apache.iotdb.relational.it.query.recent;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableLocalStandaloneIT;
+import org.apache.iotdb.itbase.env.BaseEnv;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Locale;
+
+import static org.junit.Assert.fail;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({TableLocalStandaloneIT.class})
+public class IoTExplainJsonFormatIT {
+  private static final String DATABASE_NAME = "testdb_json";
+
+  @BeforeClass
+  public static void setUp() {
+    Locale.setDefault(Locale.ENGLISH);
+
+    EnvFactory.getEnv().getConfig().getCommonConfig().setPartitionInterval(1000);
+    EnvFactory.getEnv().initClusterEnvironment();
+
+    try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = connection.createStatement()) {
+      statement.execute("CREATE DATABASE IF NOT EXISTS " + DATABASE_NAME);
+      statement.execute("USE " + DATABASE_NAME);
+      statement.execute(
+          "CREATE TABLE IF NOT EXISTS testtb(deviceid STRING TAG, voltage FLOAT FIELD)");
+      statement.execute("INSERT INTO testtb VALUES(1000, 'd1', 100.0)");
+      statement.execute("INSERT INTO testtb VALUES(2000, 'd1', 200.0)");
+      statement.execute("INSERT INTO testtb VALUES(1000, 'd2', 300.0)");
+    } catch (Exception e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @AfterClass
+  public static void tearDown() {
+    try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = connection.createStatement()) {
+      statement.execute("DROP DATABASE IF EXISTS " + DATABASE_NAME);
+    } catch (Exception e) {
+      // ignore
+    }
+    EnvFactory.getEnv().cleanClusterEnvironment();
+  }
+
+  @Test
+  public void testExplainJsonFormat() {
+    String sql = "EXPLAIN (FORMAT JSON) SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      Assert.assertNotNull(jsonStr);
+
+      // Verify it's valid JSON
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+      Assert.assertTrue("JSON should have 'name' field", root.has("name"));
+      Assert.assertTrue("JSON should have 'id' field", root.has("id"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainDefaultFormatIsNotJson() {
+    String sql = "EXPLAIN SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String firstLine = resultSet.getString(1);
+      // Default format (GRAPHVIZ) produces box-drawing characters, not JSON
+      Assert.assertFalse(
+          "Default EXPLAIN should not produce JSON", firstLine.trim().startsWith("{"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeJsonFormat() {
+    String sql = "EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      // JSON format should produce a single row with full JSON
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      Assert.assertNotNull(jsonStr);
+
+      // Verify it's valid JSON
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+      Assert.assertTrue("JSON should have 'planStatistics' field", root.has("planStatistics"));
+      Assert.assertTrue(
+          "JSON should have 'fragmentInstances' field", root.has("fragmentInstances"));
+      Assert.assertTrue(
+          "JSON should have 'fragmentInstancesCount' field", root.has("fragmentInstancesCount"));
+
+      // Plan statistics should contain known keys
+      JsonObject planStats = root.getAsJsonObject("planStatistics");
+      Assert.assertTrue(planStats.has("analyzeCostMs"));
+      Assert.assertTrue(planStats.has("logicalPlanCostMs"));
+      Assert.assertTrue(planStats.has("distributionPlanCostMs"));
+      Assert.assertTrue(planStats.has("dispatchCostMs"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeVerboseJsonFormat() {
+    String sql = "EXPLAIN ANALYZE VERBOSE (FORMAT JSON) SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      Assert.assertNotNull(jsonStr);
+
+      // Verify it's valid JSON
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+      Assert.assertTrue(root.has("planStatistics"));
+      Assert.assertTrue(root.has("fragmentInstances"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeDefaultIsText() {
+    String sql = "EXPLAIN ANALYZE SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String firstLine = resultSet.getString(1);
+      // Default format (TEXT) should not start with '{'
+      Assert.assertFalse(
+          "Default EXPLAIN ANALYZE should not produce JSON", firstLine.trim().startsWith("{"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainGraphvizFormatExplicit() {
+    String sql = "EXPLAIN (FORMAT GRAPHVIZ) SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String firstLine = resultSet.getString(1);
+      // GRAPHVIZ format produces box-drawing characters, not JSON
+      Assert.assertFalse(
+          "GRAPHVIZ EXPLAIN should not produce JSON", firstLine.trim().startsWith("{"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeTextFormatExplicit() {
+    String sql = "EXPLAIN ANALYZE (FORMAT TEXT) SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue("Should have at least one row", resultSet.next());
+      String firstLine = resultSet.getString(1);
+      Assert.assertFalse(
+          "TEXT EXPLAIN ANALYZE should not produce JSON", firstLine.trim().startsWith("{"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test(expected = SQLException.class)
+  public void testExplainInvalidFormat() throws SQLException {
+    String sql = "EXPLAIN (FORMAT XML) SELECT * FROM testtb";
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      statement.executeQuery(sql);
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java
index 88bd1998f68..43b214f226c 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/common/MPPQueryContext.java
@@ -34,6 +34,7 @@ import org.apache.iotdb.db.queryengine.plan.analyze.lock.SchemaLockType;
 import org.apache.iotdb.db.queryengine.plan.planner.memory.MemoryReservationManager;
 import org.apache.iotdb.db.queryengine.plan.planner.memory.NotThreadSafeMemoryReservationManager;
 import org.apache.iotdb.db.queryengine.plan.relational.analyzer.NodeRef;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Identifier;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Query;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Table;
@@ -108,6 +109,7 @@ public class MPPQueryContext implements IAuditEntity {
   // - EXPLAIN: Show the logical and physical query plan without execution
   // - EXPLAIN_ANALYZE: Execute the query and collect detailed execution statistics
   private ExplainType explainType = ExplainType.NONE;
+  private ExplainOutputFormat explainOutputFormat = null;
   private boolean verbose = false;
 
   private QueryPlanStatistics queryPlanStatistics = null;
@@ -346,6 +348,14 @@ public class MPPQueryContext implements IAuditEntity {
     return explainType;
   }
 
+  public void setExplainOutputFormat(ExplainOutputFormat explainOutputFormat) {
+    this.explainOutputFormat = explainOutputFormat;
+  }
+
+  public ExplainOutputFormat getExplainOutputFormat() {
+    return explainOutputFormat;
+  }
+
   public boolean isExplainAnalyze() {
     return explainType == ExplainType.EXPLAIN_ANALYZE;
   }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java
index b4f1a5c7261..52af0bb7455 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperator.java
@@ -33,8 +33,10 @@ import org.apache.iotdb.db.queryengine.plan.Coordinator;
 import org.apache.iotdb.db.queryengine.plan.execution.QueryExecution;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
 import org.apache.iotdb.db.queryengine.plan.relational.analyzer.NodeRef;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Table;
 import org.apache.iotdb.db.queryengine.statistics.FragmentInstanceStatisticsDrawer;
+import org.apache.iotdb.db.queryengine.statistics.FragmentInstanceStatisticsJsonDrawer;
 import org.apache.iotdb.db.queryengine.statistics.QueryStatisticsFetcher;
 import org.apache.iotdb.db.queryengine.statistics.StatisticLine;
 import org.apache.iotdb.db.utils.SetThreadName;
@@ -78,9 +80,12 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
   private final boolean verbose;
   private boolean outputResult = false;
   private final List<FragmentInstance> instances;
+  private final ExplainOutputFormat outputFormat;
 
   private final FragmentInstanceStatisticsDrawer fragmentInstanceStatisticsDrawer =
       new FragmentInstanceStatisticsDrawer();
+  private final FragmentInstanceStatisticsJsonDrawer fragmentInstanceStatisticsJsonDrawer =
+      new FragmentInstanceStatisticsJsonDrawer();
 
   private final ScheduledFuture<?> logRecordTask;
   private final IClientManager<TEndPoint, SyncDataNodeInternalServiceClient> clientManager;
@@ -92,9 +97,20 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
       long queryId,
       boolean verbose,
       long timeout) {
+    this(operatorContext, child, queryId, verbose, timeout, ExplainOutputFormat.TEXT);
+  }
+
+  public ExplainAnalyzeOperator(
+      OperatorContext operatorContext,
+      Operator child,
+      long queryId,
+      boolean verbose,
+      long timeout,
+      ExplainOutputFormat outputFormat) {
     this.operatorContext = operatorContext;
     this.child = child;
     this.verbose = verbose;
+    this.outputFormat = outputFormat;
     Coordinator coordinator = Coordinator.getInstance();
 
     this.clientManager = coordinator.getInternalServiceClientManager();
@@ -103,6 +119,7 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
     this.instances = queryExecution.getDistributedPlan().getInstances();
     mppQueryContext = queryExecution.getContext();
     fragmentInstanceStatisticsDrawer.renderPlanStatistics(mppQueryContext);
+    fragmentInstanceStatisticsJsonDrawer.renderPlanStatistics(mppQueryContext);
 
     // The time interval guarantees the result of EXPLAIN ANALYZE will be printed at least three
     // times.
@@ -130,6 +147,7 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
     }
 
     fragmentInstanceStatisticsDrawer.renderDispatchCost(mppQueryContext);
+    fragmentInstanceStatisticsJsonDrawer.renderDispatchCost(mppQueryContext);
 
     // fetch statics from all fragment instances
     TsBlock result = buildResult();
@@ -182,6 +200,9 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
   }
 
   private TsBlock buildResult() throws FragmentInstanceFetchException {
+    if (outputFormat == ExplainOutputFormat.JSON) {
+      return buildJsonResult();
+    }
     Map<NodeRef<Table>, Pair<Integer, List<String>>> cteAnalyzeResults =
         mppQueryContext.getCteExplainResults();
     List<String> mainAnalyzeResult = buildFragmentInstanceStatistics(instances, verbose);
@@ -199,6 +220,22 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
     return builder.build();
   }
 
+  private TsBlock buildJsonResult() throws FragmentInstanceFetchException {
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStatistics =
+        QueryStatisticsFetcher.fetchAllStatistics(instances, clientManager);
+    String jsonResult =
+        fragmentInstanceStatisticsJsonDrawer.renderFragmentInstancesAsJson(
+            instances, allStatistics, verbose);
+
+    TsBlockBuilder builder = new TsBlockBuilder(Collections.singletonList(TSDataType.TEXT));
+    TimeColumnBuilder timeColumnBuilder = builder.getTimeColumnBuilder();
+    ColumnBuilder columnBuilder = builder.getColumnBuilder(0);
+    timeColumnBuilder.writeLong(0);
+    columnBuilder.writeBinary(new Binary(jsonResult.getBytes()));
+    builder.declarePosition();
+    return builder.build();
+  }
+
   private List<String> mergeAnalyzeResults(
       Map<NodeRef<Table>, Pair<Integer, List<String>>> cteAnalyzeResults,
       List<String> mainAnalyzeResult) {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java
index 932d9419792..c6a7692395a 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitor.java
@@ -25,6 +25,7 @@ import org.apache.iotdb.db.queryengine.common.header.DatasetHeader;
 import org.apache.iotdb.db.queryengine.plan.Coordinator;
 import org.apache.iotdb.db.queryengine.plan.planner.LocalExecutionPlanner;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.LogicalQueryPlan;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphJsonPrinter;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphPrinter;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNode;
 import org.apache.iotdb.db.queryengine.plan.relational.analyzer.NodeRef;
@@ -35,6 +36,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.planner.distribute.TableD
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.AstVisitor;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.CountDevice;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Explain;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Node;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ShowDevice;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Table;
@@ -103,15 +105,26 @@ public class TableModelStatementMemorySourceVisitor
                 Coordinator.getInstance().getDataNodeLocationSupplier())
             .generateDistributedPlanWithOptimize(planContext);
 
-    List<String> mainExplainResult =
-        outputNodeWithExchange.accept(
-            new PlanGraphPrinter(),
-            new PlanGraphPrinter.GraphContext(
-                context.getQueryContext().getTypeProvider().getTemplatedInfo()));
+    List<String> mainExplainResult;
+    if (node.getOutputFormat() == ExplainOutputFormat.JSON) {
+      mainExplainResult = PlanGraphJsonPrinter.getJsonLines(outputNodeWithExchange);
+    } else {
+      mainExplainResult =
+          outputNodeWithExchange.accept(
+              new PlanGraphPrinter(),
+              new PlanGraphPrinter.GraphContext(
+                  context.getQueryContext().getTypeProvider().getTemplatedInfo()));
+    }
 
     Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults =
         context.getQueryContext().getCteExplainResults();
-    List<String> lines = mergeExplainResults(cteExplainResults, mainExplainResult);
+    List<String> lines;
+    if (node.getOutputFormat() == ExplainOutputFormat.JSON) {
+      // For JSON format, we merge CTE results into the JSON output
+      lines = mergeExplainResultsJson(cteExplainResults, mainExplainResult);
+    } else {
+      lines = mergeExplainResults(cteExplainResults, mainExplainResult);
+    }
 
     return getStatementMemorySource(header, lines);
   }
@@ -149,4 +162,44 @@ public class TableModelStatementMemorySourceVisitor
 
     return analyzeResult;
   }
+
+  private List<String> mergeExplainResultsJson(
+      Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults,
+      List<String> mainExplainResult) {
+    if (cteExplainResults.isEmpty()) {
+      return mainExplainResult;
+    }
+
+    // For JSON format with CTEs, wrap everything in a combined JSON object
+    StringBuilder sb = new StringBuilder();
+    sb.append("{\n");
+    sb.append("  \"cteQueries\": [\n");
+    int cteIndex = 0;
+    int cteSize = cteExplainResults.size();
+    for (Map.Entry<NodeRef<Table>, Pair<Integer, List<String>>> entry :
+        cteExplainResults.entrySet()) {
+      sb.append("    {\n");
+      sb.append("      \"name\": \"").append(entry.getKey().getNode().getName()).append("\",\n");
+      sb.append("      \"plan\": ");
+      // Each CTE's plan is already a JSON string
+      for (String line : entry.getValue().getRight()) {
+        sb.append(line);
+      }
+      sb.append("\n    }");
+      if (++cteIndex < cteSize) {
+        sb.append(",");
+      }
+      sb.append("\n");
+    }
+    sb.append("  ],\n");
+    sb.append("  \"mainQuery\": ");
+    for (String line : mainExplainResult) {
+      sb.append(line);
+    }
+    sb.append("\n}");
+
+    List<String> result = new ArrayList<>();
+    result.add(sb.toString());
+    return result;
+  }
 }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java
index c31a2061f49..fe96177ee5f 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/TableOperatorGenerator.java
@@ -3472,7 +3472,12 @@ public class TableOperatorGenerator extends PlanVisitor<Operator, LocalExecution
                 node.getPlanNodeId(),
                 ExplainAnalyzeOperator.class.getSimpleName());
     return new ExplainAnalyzeOperator(
-        operatorContext, operator, node.getQueryId(), node.isVerbose(), node.getTimeout());
+        operatorContext,
+        operator,
+        node.getQueryId(),
+        node.isVerbose(),
+        node.getTimeout(),
+        node.getOutputFormat());
   }
 
   @Override
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java
new file mode 100644
index 00000000000..e35c23d4660
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.plan.planner.plan.node;
+
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.DeviceTableScanNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExchangeNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExplainAnalyzeNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.TableScanNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.TreeDeviceViewScanNode;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Converts a plan tree into a JSON representation. Each plan node becomes a JSON object with: -
+ * "name": the node type with its plan node ID - "id": the plan node ID - "properties": key-value
+ * pairs - "children": array of child nodes
+ */
+public class PlanGraphJsonPrinter {
+
+  private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+  private static final String REGION_NOT_ASSIGNED = "Not Assigned";
+
+  public static String toPrettyJson(PlanNode node) {
+    JsonObject root = buildJsonNode(node);
+    return GSON.toJson(root);
+  }
+
+  private static JsonObject buildJsonNode(PlanNode node) {
+    JsonObject jsonNode = new JsonObject();
+    String simpleName = node.getClass().getSimpleName();
+    String nodeId = node.getPlanNodeId().getId();
+
+    jsonNode.addProperty("name", simpleName + "-" + nodeId);
+    jsonNode.addProperty("id", nodeId);
+
+    JsonObject properties = buildProperties(node);
+    if (properties.size() > 0) {
+      jsonNode.add("properties", properties);
+    }
+
+    List<PlanNode> children = node.getChildren();
+    if (children != null && !children.isEmpty()) {
+      JsonArray childrenArray = new JsonArray();
+      for (PlanNode child : children) {
+        childrenArray.add(buildJsonNode(child));
+      }
+      jsonNode.add("children", childrenArray);
+    }
+
+    return jsonNode;
+  }
+
+  private static JsonObject buildProperties(PlanNode node) {
+    JsonObject properties = new JsonObject();
+
+    if (node instanceof OutputNode) {
+      OutputNode n = (OutputNode) node;
+      properties.addProperty("OutputColumns", String.valueOf(n.getOutputColumnNames()));
+      properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+    } else if (node instanceof ExplainAnalyzeNode) {
+      ExplainAnalyzeNode n = (ExplainAnalyzeNode) node;
+      properties.addProperty("ChildPermittedOutputs", String.valueOf(n.getChildPermittedOutputs()));
+    } else if (node instanceof TableScanNode) {
+      buildTableScanProperties(properties, (TableScanNode) node);
+    } else if (node instanceof ExchangeNode) {
+      // No extra properties needed
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode) {
+      buildAggregationProperties(
+          properties,
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode) node);
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode) node;
+      properties.addProperty("Predicate", String.valueOf(n.getPredicate()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.ProjectNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.ProjectNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.ProjectNode) node;
+      properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+      properties.addProperty("Expressions", String.valueOf(n.getAssignments().getMap().values()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode) node;
+      properties.addProperty("Count", String.valueOf(n.getCount()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.OffsetNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.OffsetNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.OffsetNode) node;
+      properties.addProperty("Count", String.valueOf(n.getCount()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.SortNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.SortNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.SortNode) node;
+      properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.MergeSortNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.MergeSortNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.MergeSortNode) node;
+      properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.JoinNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.JoinNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.JoinNode) node;
+      properties.addProperty("JoinType", String.valueOf(n.getJoinType()));
+      properties.addProperty("Criteria", String.valueOf(n.getCriteria()));
+      properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+    } else if (node
+        instanceof org.apache.iotdb.db.queryengine.plan.relational.planner.node.UnionNode) {
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.UnionNode n =
+          (org.apache.iotdb.db.queryengine.plan.relational.planner.node.UnionNode) node;
+      properties.addProperty("OutputSymbols", String.valueOf(n.getOutputSymbols()));
+    }
+
+    return properties;
+  }
+
+  private static void buildTableScanProperties(JsonObject properties, TableScanNode node) {
+    properties.addProperty("QualifiedTableName", node.getQualifiedObjectName().toString());
+    properties.addProperty("OutputSymbols", String.valueOf(node.getOutputSymbols()));
+
+    if (node instanceof DeviceTableScanNode) {
+      DeviceTableScanNode deviceNode = (DeviceTableScanNode) node;
+      properties.addProperty("DeviceNumber", String.valueOf(deviceNode.getDeviceEntries().size()));
+      properties.addProperty("ScanOrder", String.valueOf(deviceNode.getScanOrder()));
+      if (deviceNode.getTimePredicate().isPresent()) {
+        properties.addProperty(
+            "TimePredicate", String.valueOf(deviceNode.getTimePredicate().get()));
+      }
+    }
+
+    if (node.getPushDownPredicate() != null) {
+      properties.addProperty("PushDownPredicate", String.valueOf(node.getPushDownPredicate()));
+    }
+    properties.addProperty("PushDownOffset", String.valueOf(node.getPushDownOffset()));
+    properties.addProperty("PushDownLimit", String.valueOf(node.getPushDownLimit()));
+
+    if (node instanceof DeviceTableScanNode) {
+      properties.addProperty(
+          "PushDownLimitToEachDevice",
+          String.valueOf(((DeviceTableScanNode) node).isPushLimitToEachDevice()));
+    }
+
+    properties.addProperty(
+        "RegionId",
+        node.getRegionReplicaSet() == null || node.getRegionReplicaSet().getRegionId() == null
+            ? REGION_NOT_ASSIGNED
+            : String.valueOf(node.getRegionReplicaSet().getRegionId().getId()));
+
+    if (node instanceof TreeDeviceViewScanNode) {
+      TreeDeviceViewScanNode treeNode = (TreeDeviceViewScanNode) node;
+      properties.addProperty("TreeDB", treeNode.getTreeDBName());
+      properties.addProperty(
+          "MeasurementToColumnName", String.valueOf(treeNode.getMeasurementColumnNameMap()));
+    }
+  }
+
+  private static void buildAggregationProperties(
+      JsonObject properties,
+      org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode node) {
+    properties.addProperty("OutputSymbols", String.valueOf(node.getOutputSymbols()));
+
+    JsonArray aggregators = new JsonArray();
+    int i = 0;
+    for (org.apache.iotdb.db.queryengine.plan.relational.planner.node.AggregationNode.Aggregation
+        aggregation : node.getAggregations().values()) {
+      JsonObject agg = new JsonObject();
+      agg.addProperty("index", i++);
+      agg.addProperty("function", aggregation.getResolvedFunction().toString());
+      if (aggregation.hasMask()) {
+        agg.addProperty("mask", String.valueOf(aggregation.getMask().get()));
+      }
+      if (aggregation.isDistinct()) {
+        agg.addProperty("distinct", true);
+      }
+      aggregators.add(agg);
+    }
+    properties.add("Aggregators", aggregators);
+
+    properties.addProperty("GroupingKeys", String.valueOf(node.getGroupingKeys()));
+    if (node.isStreamable()) {
+      properties.addProperty("Streamable", true);
+      properties.addProperty("PreGroupedSymbols", String.valueOf(node.getPreGroupedSymbols()));
+    }
+    properties.addProperty("Step", String.valueOf(node.getStep()));
+  }
+
+  public static List<String> getJsonLines(PlanNode node) {
+    List<String> lines = new ArrayList<>();
+    lines.add(toPrettyJson(node));
+    return lines;
+  }
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
index cff29c0d83a..eb4e1241d14 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/analyzer/StatementAnalyzer.java
@@ -847,6 +847,7 @@ public class StatementAnalyzer {
     @Override
     protected Scope visitExplain(Explain node, Optional<Scope> context) {
       queryContext.setExplainType(ExplainType.EXPLAIN);
+      queryContext.setExplainOutputFormat(node.getOutputFormat());
       analysis.setFinishQueryAfterAnalyze();
       return visitQuery((Query) node.getStatement(), context);
     }
@@ -863,6 +864,7 @@ public class StatementAnalyzer {
     protected Scope visitExplainAnalyze(ExplainAnalyze node, Optional<Scope> context) {
       queryContext.setExplainType(ExplainType.EXPLAIN_ANALYZE);
       queryContext.setVerbose(node.isVerbose());
+      queryContext.setExplainOutputFormat(node.getOutputFormat());
       return visitQuery((Query) node.getStatement(), context);
     }
 
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java
index c33cb05250b..538890b1568 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/TableLogicalPlanner.java
@@ -625,7 +625,8 @@ public class TableLogicalPlanner {
             queryContext.getTimeOut(),
             symbol,
             // recording permittedOutputs of ExplainAnalyzeNode's child
-            getChildPermittedOutputs(analysis, statement.getStatement(), originalQueryPlan));
+            getChildPermittedOutputs(analysis, statement.getStatement(), originalQueryPlan),
+            statement.getOutputFormat());
     return new RelationPlan(
         newRoot,
         originalQueryPlan.getScope(),
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java
index 23aa8baad2d..d50d07521ec 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/node/ExplainAnalyzeNode.java
@@ -24,6 +24,7 @@ import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNodeId;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanVisitor;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.process.SingleChildProcessNode;
 import org.apache.iotdb.db.queryengine.plan.relational.planner.Symbol;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 
 import java.io.DataOutputStream;
 import java.io.IOException;
@@ -38,6 +39,7 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
   private final long timeout;
   private final Symbol outputSymbol;
   private final List<Symbol> childPermittedOutputs;
+  private final ExplainOutputFormat outputFormat;
 
   public ExplainAnalyzeNode(
       PlanNodeId id,
@@ -47,18 +49,46 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
       long timeout,
       Symbol outputSymbol,
       List<Symbol> childPermittedOutputs) {
+    this(
+        id,
+        child,
+        verbose,
+        queryId,
+        timeout,
+        outputSymbol,
+        childPermittedOutputs,
+        ExplainOutputFormat.TEXT);
+  }
+
+  public ExplainAnalyzeNode(
+      PlanNodeId id,
+      PlanNode child,
+      boolean verbose,
+      long queryId,
+      long timeout,
+      Symbol outputSymbol,
+      List<Symbol> childPermittedOutputs,
+      ExplainOutputFormat outputFormat) {
     super(id, child);
     this.verbose = verbose;
     this.timeout = timeout;
     this.queryId = queryId;
     this.outputSymbol = outputSymbol;
     this.childPermittedOutputs = childPermittedOutputs;
+    this.outputFormat = outputFormat;
   }
 
   @Override
   public PlanNode clone() {
     return new ExplainAnalyzeNode(
-        getPlanNodeId(), child, verbose, queryId, timeout, outputSymbol, childPermittedOutputs);
+        getPlanNodeId(),
+        child,
+        verbose,
+        queryId,
+        timeout,
+        outputSymbol,
+        childPermittedOutputs,
+        outputFormat);
   }
 
   @Override
@@ -89,7 +119,8 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
         queryId,
         timeout,
         outputSymbol,
-        childPermittedOutputs);
+        childPermittedOutputs,
+        outputFormat);
   }
 
   // ExplainAnalyze should be at the same region as Coordinator all the time. Therefore, there will
@@ -116,6 +147,10 @@ public class ExplainAnalyzeNode extends SingleChildProcessNode {
     return timeout;
   }
 
+  public ExplainOutputFormat getOutputFormat() {
+    return outputFormat;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (this == o) return true;
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java
index f3a50175269..6e51e9bae60 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/Explain.java
@@ -33,21 +33,34 @@ public class Explain extends Statement {
   private static final long INSTANCE_SIZE = RamUsageEstimator.shallowSizeOfInstance(Explain.class);
 
   private final Statement statement;
+  private final ExplainOutputFormat outputFormat;
 
   public Explain(Statement statement) {
     super(null);
     this.statement = requireNonNull(statement, "statement is null");
+    this.outputFormat = ExplainOutputFormat.GRAPHVIZ;
   }
 
   public Explain(NodeLocation location, Statement statement) {
     super(requireNonNull(location, "location is null"));
     this.statement = requireNonNull(statement, "statement is null");
+    this.outputFormat = ExplainOutputFormat.GRAPHVIZ;
+  }
+
+  public Explain(NodeLocation location, Statement statement, ExplainOutputFormat outputFormat) {
+    super(requireNonNull(location, "location is null"));
+    this.statement = requireNonNull(statement, "statement is null");
+    this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
   }
 
   public Statement getStatement() {
     return statement;
   }
 
+  public ExplainOutputFormat getOutputFormat() {
+    return outputFormat;
+  }
+
   @Override
   public <R, C> R accept(AstVisitor<R, C> visitor, C context) {
     return visitor.visitExplain(this, context);
@@ -60,7 +73,7 @@ public class Explain extends Statement {
 
   @Override
   public int hashCode() {
-    return Objects.hash(statement);
+    return Objects.hash(statement, outputFormat);
   }
 
   @Override
@@ -72,12 +85,15 @@ public class Explain extends Statement {
       return false;
     }
     Explain o = (Explain) obj;
-    return Objects.equals(statement, o.statement);
+    return Objects.equals(statement, o.statement) && outputFormat == o.outputFormat;
   }
 
   @Override
   public String toString() {
-    return toStringHelper(this).add("statement", statement).toString();
+    return toStringHelper(this)
+        .add("statement", statement)
+        .add("outputFormat", outputFormat)
+        .toString();
   }
 
   @Override
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java
index cd3df879fc5..dc7798967e4 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainAnalyze.java
@@ -35,17 +35,31 @@ public class ExplainAnalyze extends Statement {
 
   private final Statement statement;
   private final boolean verbose;
+  private final ExplainOutputFormat outputFormat;
 
   public ExplainAnalyze(Statement statement, boolean verbose) {
     super(null);
     this.statement = requireNonNull(statement, "statement is null");
     this.verbose = verbose;
+    this.outputFormat = ExplainOutputFormat.TEXT;
   }
 
   public ExplainAnalyze(NodeLocation location, boolean verbose, Statement statement) {
     super(requireNonNull(location, "location is null"));
     this.statement = requireNonNull(statement, "statement is null");
     this.verbose = verbose;
+    this.outputFormat = ExplainOutputFormat.TEXT;
+  }
+
+  public ExplainAnalyze(
+      NodeLocation location,
+      boolean verbose,
+      Statement statement,
+      ExplainOutputFormat outputFormat) {
+    super(requireNonNull(location, "location is null"));
+    this.statement = requireNonNull(statement, "statement is null");
+    this.verbose = verbose;
+    this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
   }
 
   public Statement getStatement() {
@@ -56,6 +70,10 @@ public class ExplainAnalyze extends Statement {
     return verbose;
   }
 
+  public ExplainOutputFormat getOutputFormat() {
+    return outputFormat;
+  }
+
   @Override
   public <R, C> R accept(AstVisitor<R, C> visitor, C context) {
     return visitor.visitExplainAnalyze(this, context);
@@ -85,7 +103,11 @@ public class ExplainAnalyze extends Statement {
 
   @Override
   public String toString() {
-    return toStringHelper(this).add("statement", statement).add("verbose", verbose).toString();
+    return toStringHelper(this)
+        .add("statement", statement)
+        .add("verbose", verbose)
+        .add("outputFormat", outputFormat)
+        .toString();
   }
 
   @Override
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java
new file mode 100644
index 00000000000..9dba3649a7f
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.plan.relational.sql.ast;
+
+public enum ExplainOutputFormat {
+  GRAPHVIZ,
+  TEXT,
+  JSON
+}
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
index a31b48c24b4..c825d6b92cb 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/parser/AstBuilder.java
@@ -98,6 +98,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExecuteImmediate;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExistsPredicate;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Explain;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainAnalyze;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Expression;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExtendRegion;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Extract;
@@ -1804,7 +1805,22 @@ public class AstBuilder extends RelationalSqlBaseVisitor<Node> {
     } else {
       innerStatement = (Statement) visit(ctx.executeImmediateStatement());
     }
-    return new Explain(getLocation(ctx), innerStatement);
+    ExplainOutputFormat format = ExplainOutputFormat.GRAPHVIZ;
+    if (ctx.identifier() != null) {
+      String formatStr = ((Identifier) visit(ctx.identifier())).getValue().toUpperCase();
+      switch (formatStr) {
+        case "GRAPHVIZ":
+          format = ExplainOutputFormat.GRAPHVIZ;
+          break;
+        case "JSON":
+          format = ExplainOutputFormat.JSON;
+          break;
+        default:
+          throw new SemanticException(
+              "Invalid EXPLAIN format: " + formatStr + ". Supported formats: GRAPHVIZ, JSON");
+      }
+    }
+    return new Explain(getLocation(ctx), innerStatement, format);
   }
 
   @Override
@@ -1817,7 +1833,22 @@ public class AstBuilder extends RelationalSqlBaseVisitor<Node> {
     } else {
       innerStatement = (Statement) visit(ctx.executeImmediateStatement());
     }
-    return new ExplainAnalyze(getLocation(ctx), ctx.VERBOSE() != null, innerStatement);
+    ExplainOutputFormat format = ExplainOutputFormat.TEXT;
+    if (ctx.identifier() != null) {
+      String formatStr = ((Identifier) visit(ctx.identifier())).getValue().toUpperCase();
+      switch (formatStr) {
+        case "TEXT":
+          format = ExplainOutputFormat.TEXT;
+          break;
+        case "JSON":
+          format = ExplainOutputFormat.JSON;
+          break;
+        default:
+          throw new SemanticException(
+              "Invalid EXPLAIN ANALYZE format: " + formatStr + ". Supported formats: TEXT, JSON");
+      }
+    }
+    return new ExplainAnalyze(getLocation(ctx), ctx.VERBOSE() != null, innerStatement, format);
   }
 
   // ********************** author expressions ********************
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java
new file mode 100644
index 00000000000..3c6ff7bfb41
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java
@@ -0,0 +1,371 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.statistics;
+
+import org.apache.iotdb.db.queryengine.common.FragmentInstanceId;
+import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNode;
+import org.apache.iotdb.mpp.rpc.thrift.TFetchFragmentInstanceStatisticsResp;
+import org.apache.iotdb.mpp.rpc.thrift.TOperatorStatistics;
+import org.apache.iotdb.mpp.rpc.thrift.TQueryStatistics;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Produces JSON output for EXPLAIN ANALYZE results, mirroring the same data as {@link
+ * FragmentInstanceStatisticsDrawer} but in JSON format.
+ */
+public class FragmentInstanceStatisticsJsonDrawer {
+
+  private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+  private static final double NS_TO_MS_FACTOR = 1.0 / 1000000;
+  private static final double EPSILON = 1e-10;
+
+  private final JsonObject planStatistics = new JsonObject();
+
+  public void renderPlanStatistics(MPPQueryContext context) {
+    planStatistics.addProperty(
+        "analyzeCostMs", formatMs(context.getAnalyzeCost() * NS_TO_MS_FACTOR));
+    planStatistics.addProperty(
+        "fetchPartitionCostMs", formatMs(context.getFetchPartitionCost() * NS_TO_MS_FACTOR));
+    planStatistics.addProperty(
+        "fetchSchemaCostMs", formatMs(context.getFetchSchemaCost() * NS_TO_MS_FACTOR));
+    planStatistics.addProperty(
+        "logicalPlanCostMs", formatMs(context.getLogicalPlanCost() * NS_TO_MS_FACTOR));
+    planStatistics.addProperty(
+        "logicalOptimizationCostMs",
+        formatMs(context.getLogicalOptimizationCost() * NS_TO_MS_FACTOR));
+    planStatistics.addProperty(
+        "distributionPlanCostMs", formatMs(context.getDistributionPlanCost() * NS_TO_MS_FACTOR));
+  }
+
+  public void renderDispatchCost(MPPQueryContext context) {
+    planStatistics.addProperty(
+        "dispatchCostMs", formatMs(context.getDispatchCost() * NS_TO_MS_FACTOR));
+  }
+
+  public String renderFragmentInstancesAsJson(
+      List<FragmentInstance> instancesToBeRendered,
+      Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStatistics,
+      boolean verbose) {
+
+    JsonObject root = new JsonObject();
+    root.add("planStatistics", planStatistics);
+
+    List<FragmentInstance> validInstances =
+        instancesToBeRendered.stream()
+            .filter(
+                instance -> {
+                  TFetchFragmentInstanceStatisticsResp statistics =
+                      allStatistics.get(instance.getId());
+                  return statistics != null && statistics.getDataRegion() != null;
+                })
+            .collect(Collectors.toList());
+
+    root.addProperty("fragmentInstancesCount", validInstances.size());
+
+    JsonArray fragmentInstancesArray = new JsonArray();
+    for (FragmentInstance instance : validInstances) {
+      TFetchFragmentInstanceStatisticsResp statistics = allStatistics.get(instance.getId());
+      JsonObject fiJson = new JsonObject();
+
+      fiJson.addProperty("id", instance.getId().toString());
+      fiJson.addProperty("ip", statistics.getIp());
+      fiJson.addProperty("dataRegion", statistics.getDataRegion());
+      fiJson.addProperty("state", statistics.getState());
+      fiJson.addProperty(
+          "totalWallTimeMs", statistics.getEndTimeInMS() - statistics.getStartTimeInMS());
+      fiJson.addProperty(
+          "initDataQuerySourceCostMs",
+          formatMs(statistics.getInitDataQuerySourceCost() * NS_TO_MS_FACTOR));
+
+      if (statistics.isSetInitDataQuerySourceRetryCount()
+          && statistics.getInitDataQuerySourceRetryCount() > 0) {
+        fiJson.addProperty(
+            "initDataQuerySourceRetryCount", statistics.getInitDataQuerySourceRetryCount());
+      }
+
+      fiJson.addProperty("seqFileUnclosed", statistics.getSeqUnclosedNum());
+      fiJson.addProperty("seqFileClosed", statistics.getSeqClosednNum());
+      fiJson.addProperty("unseqFileUnclosed", statistics.getUnseqUnclosedNum());
+      fiJson.addProperty("unseqFileClosed", statistics.getUnseqClosedNum());
+      fiJson.addProperty(
+          "readyQueuedTimeMs", formatMs(statistics.getReadyQueuedTime() * NS_TO_MS_FACTOR));
+      fiJson.addProperty(
+          "blockQueuedTimeMs", formatMs(statistics.getBlockQueuedTime() * NS_TO_MS_FACTOR));
+
+      // Query statistics
+      JsonObject queryStats = renderQueryStatisticsJson(statistics.getQueryStatistics(), verbose);
+      fiJson.add("queryStatistics", queryStats);
+
+      // Operators
+      PlanNode planNodeTree = instance.getFragment().getPlanNodeTree();
+      JsonObject operatorTree =
+          renderOperatorJson(planNodeTree, statistics.getOperatorStatisticsMap());
+      if (operatorTree != null) {
+        fiJson.add("operators", operatorTree);
+      }
+
+      fragmentInstancesArray.add(fiJson);
+    }
+
+    root.add("fragmentInstances", fragmentInstancesArray);
+    return GSON.toJson(root);
+  }
+
+  private JsonObject renderQueryStatisticsJson(TQueryStatistics qs, boolean verbose) {
+    JsonObject stats = new JsonObject();
+
+    if (verbose) {
+      stats.addProperty("loadBloomFilterFromCacheCount", qs.loadBloomFilterFromCacheCount);
+      stats.addProperty("loadBloomFilterFromDiskCount", qs.loadBloomFilterFromDiskCount);
+      stats.addProperty("loadBloomFilterActualIOSize", qs.loadBloomFilterActualIOSize);
+      stats.addProperty(
+          "loadBloomFilterTimeMs", formatMs(qs.loadBloomFilterTime * NS_TO_MS_FACTOR));
+
+      addIfNonZero(
+          stats, "loadTimeSeriesMetadataDiskSeqCount", qs.loadTimeSeriesMetadataDiskSeqCount);
+      addIfNonZero(
+          stats, "loadTimeSeriesMetadataDiskUnSeqCount", qs.loadTimeSeriesMetadataDiskUnSeqCount);
+      addIfNonZero(
+          stats, "loadTimeSeriesMetadataMemSeqCount", qs.loadTimeSeriesMetadataMemSeqCount);
+      addIfNonZero(
+          stats, "loadTimeSeriesMetadataMemUnSeqCount", qs.loadTimeSeriesMetadataMemUnSeqCount);
+      addIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedDiskSeqCount",
+          qs.loadTimeSeriesMetadataAlignedDiskSeqCount);
+      addIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedDiskUnSeqCount",
+          qs.loadTimeSeriesMetadataAlignedDiskUnSeqCount);
+      addIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedMemSeqCount",
+          qs.loadTimeSeriesMetadataAlignedMemSeqCount);
+      addIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedMemUnSeqCount",
+          qs.loadTimeSeriesMetadataAlignedMemUnSeqCount);
+
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataDiskSeqTimeMs",
+          qs.loadTimeSeriesMetadataDiskSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataDiskUnSeqTimeMs",
+          qs.loadTimeSeriesMetadataDiskUnSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataMemSeqTimeMs",
+          qs.loadTimeSeriesMetadataMemSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataMemUnSeqTimeMs",
+          qs.loadTimeSeriesMetadataMemUnSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedDiskSeqTimeMs",
+          qs.loadTimeSeriesMetadataAlignedDiskSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedDiskUnSeqTimeMs",
+          qs.loadTimeSeriesMetadataAlignedDiskUnSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedMemSeqTimeMs",
+          qs.loadTimeSeriesMetadataAlignedMemSeqTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "loadTimeSeriesMetadataAlignedMemUnSeqTimeMs",
+          qs.loadTimeSeriesMetadataAlignedMemUnSeqTime * NS_TO_MS_FACTOR);
+
+      stats.addProperty(
+          "loadTimeSeriesMetadataFromCacheCount", qs.loadTimeSeriesMetadataFromCacheCount);
+      stats.addProperty(
+          "loadTimeSeriesMetadataFromDiskCount", qs.loadTimeSeriesMetadataFromDiskCount);
+      stats.addProperty(
+          "loadTimeSeriesMetadataActualIOSize", qs.loadTimeSeriesMetadataActualIOSize);
+
+      addIfNonZero(
+          stats,
+          "alignedTimeSeriesMetadataModificationCount",
+          qs.getAlignedTimeSeriesMetadataModificationCount());
+      addMsIfNonZero(
+          stats,
+          "alignedTimeSeriesMetadataModificationTimeMs",
+          qs.getAlignedTimeSeriesMetadataModificationTime() * NS_TO_MS_FACTOR);
+      addIfNonZero(
+          stats,
+          "nonAlignedTimeSeriesMetadataModificationCount",
+          qs.getNonAlignedTimeSeriesMetadataModificationCount());
+      addMsIfNonZero(
+          stats,
+          "nonAlignedTimeSeriesMetadataModificationTimeMs",
+          qs.getNonAlignedTimeSeriesMetadataModificationTime() * NS_TO_MS_FACTOR);
+
+      addIfNonZero(
+          stats,
+          "constructNonAlignedChunkReadersDiskCount",
+          qs.constructNonAlignedChunkReadersDiskCount);
+      addIfNonZero(
+          stats,
+          "constructNonAlignedChunkReadersMemCount",
+          qs.constructNonAlignedChunkReadersMemCount);
+      addIfNonZero(
+          stats, "constructAlignedChunkReadersDiskCount", qs.constructAlignedChunkReadersDiskCount);
+      addIfNonZero(
+          stats, "constructAlignedChunkReadersMemCount", qs.constructAlignedChunkReadersMemCount);
+      addMsIfNonZero(
+          stats,
+          "constructNonAlignedChunkReadersDiskTimeMs",
+          qs.constructNonAlignedChunkReadersDiskTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "constructNonAlignedChunkReadersMemTimeMs",
+          qs.constructNonAlignedChunkReadersMemTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "constructAlignedChunkReadersDiskTimeMs",
+          qs.constructAlignedChunkReadersDiskTime * NS_TO_MS_FACTOR);
+      addMsIfNonZero(
+          stats,
+          "constructAlignedChunkReadersMemTimeMs",
+          qs.constructAlignedChunkReadersMemTime * NS_TO_MS_FACTOR);
+
+      stats.addProperty("loadChunkFromCacheCount", qs.loadChunkFromCacheCount);
+      stats.addProperty("loadChunkFromDiskCount", qs.loadChunkFromDiskCount);
+      stats.addProperty("loadChunkActualIOSize", qs.loadChunkActualIOSize);
+
+      addIfNonZero(
+          stats, "pageReadersDecodeAlignedDiskCount", qs.pageReadersDecodeAlignedDiskCount);
+      addMsIfNonZero(
+          stats,
+          "pageReadersDecodeAlignedDiskTimeMs",
+          qs.pageReadersDecodeAlignedDiskTime * NS_TO_MS_FACTOR);
+      addIfNonZero(stats, "pageReadersDecodeAlignedMemCount", qs.pageReadersDecodeAlignedMemCount);
+      addMsIfNonZero(
+          stats,
+          "pageReadersDecodeAlignedMemTimeMs",
+          qs.pageReadersDecodeAlignedMemTime * NS_TO_MS_FACTOR);
+      addIfNonZero(
+          stats, "pageReadersDecodeNonAlignedDiskCount", qs.pageReadersDecodeNonAlignedDiskCount);
+      addMsIfNonZero(
+          stats,
+          "pageReadersDecodeNonAlignedDiskTimeMs",
+          qs.pageReadersDecodeNonAlignedDiskTime * NS_TO_MS_FACTOR);
+      addIfNonZero(
+          stats, "pageReadersDecodeNonAlignedMemCount", qs.pageReadersDecodeNonAlignedMemCount);
+      addMsIfNonZero(
+          stats,
+          "pageReadersDecodeNonAlignedMemTimeMs",
+          qs.pageReadersDecodeNonAlignedMemTime * NS_TO_MS_FACTOR);
+      addIfNonZero(stats, "pageReaderMaxUsedMemorySize", qs.pageReaderMaxUsedMemorySize);
+      addIfNonZero(stats, "chunkWithMetadataErrorsCount", qs.chunkWithMetadataErrorsCount);
+    }
+
+    stats.addProperty("timeSeriesIndexFilteredRows", qs.timeSeriesIndexFilteredRows);
+    stats.addProperty("chunkIndexFilteredRows", qs.chunkIndexFilteredRows);
+    stats.addProperty("pageIndexFilteredRows", qs.pageIndexFilteredRows);
+
+    if (verbose) {
+      stats.addProperty("rowScanFilteredRows", qs.rowScanFilteredRows);
+    }
+
+    return stats;
+  }
+
+  private JsonObject renderOperatorJson(
+      PlanNode planNodeTree, Map<String, TOperatorStatistics> operatorStatistics) {
+    if (planNodeTree == null) {
+      return null;
+    }
+
+    JsonObject operatorJson = new JsonObject();
+    TOperatorStatistics opStats = operatorStatistics.get(planNodeTree.getPlanNodeId().toString());
+
+    operatorJson.addProperty("planNodeId", planNodeTree.getPlanNodeId().toString());
+    operatorJson.addProperty("nodeType", planNodeTree.getClass().getSimpleName());
+
+    if (opStats != null) {
+      operatorJson.addProperty("operatorType", opStats.getOperatorType());
+      if (opStats.isSetCount()) {
+        operatorJson.addProperty("count", opStats.getCount());
+      }
+      operatorJson.addProperty(
+          "cpuTimeMs", formatMs(opStats.getTotalExecutionTimeInNanos() * NS_TO_MS_FACTOR));
+      operatorJson.addProperty("outputRows", opStats.getOutputRows());
+      operatorJson.addProperty("hasNextCalledCount", opStats.hasNextCalledCount);
+      operatorJson.addProperty("nextCalledCount", opStats.nextCalledCount);
+      if (opStats.getMemoryUsage() != 0) {
+        operatorJson.addProperty("estimatedMemorySize", opStats.getMemoryUsage());
+      }
+
+      if (opStats.getSpecifiedInfoSize() != 0) {
+        JsonObject specifiedInfo = new JsonObject();
+        for (Map.Entry<String, String> entry : opStats.getSpecifiedInfo().entrySet()) {
+          specifiedInfo.addProperty(entry.getKey(), entry.getValue());
+        }
+        operatorJson.add("specifiedInfo", specifiedInfo);
+      }
+    }
+
+    List<PlanNode> children = planNodeTree.getChildren();
+    if (children != null && !children.isEmpty()) {
+      JsonArray childrenArray = new JsonArray();
+      for (PlanNode child : children) {
+        JsonObject childJson = renderOperatorJson(child, operatorStatistics);
+        if (childJson != null) {
+          childrenArray.add(childJson);
+        }
+      }
+      if (childrenArray.size() > 0) {
+        operatorJson.add("children", childrenArray);
+      }
+    }
+
+    return operatorJson;
+  }
+
+  private static double formatMs(double ms) {
+    return Math.round(ms * 1000.0) / 1000.0;
+  }
+
+  private static void addIfNonZero(JsonObject obj, String key, long value) {
+    if (value != 0) {
+      obj.addProperty(key, value);
+    }
+  }
+
+  private static void addMsIfNonZero(JsonObject obj, String key, double value) {
+    if (Math.abs(value) > EPSILON) {
+      obj.addProperty(key, formatMs(value));
+    }
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java
new file mode 100644
index 00000000000..a600957b25f
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.plan.planner.node;
+
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphJsonPrinter;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.Symbol;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.FilterNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ComparisonExpression;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.GenericLiteral;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.SymbolReference;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class PlanGraphJsonPrinterTest {
+
+  @Test
+  public void testSimplePlanToJson() {
+    // Build a simple plan: Output -> Limit -> Filter
+    LimitNode placeholder = new LimitNode(new PlanNodeId("placeholder"), null, 0, Optional.empty());
+    FilterNode filterNode =
+        new FilterNode(
+            new PlanNodeId("3"),
+            placeholder,
+            new ComparisonExpression(
+                ComparisonExpression.Operator.EQUAL,
+                new SymbolReference("s1"),
+                new GenericLiteral("INT32", "1")));
+
+    LimitNode limitNode = new LimitNode(new PlanNodeId("2"), filterNode, 10, Optional.empty());
+
+    List<Symbol> outputSymbols = Arrays.asList(new Symbol("s1"), new Symbol("s2"));
+    OutputNode outputNode =
+        new OutputNode(new PlanNodeId("1"), limitNode, Arrays.asList("s1", "s2"), outputSymbols);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(outputNode);
+
+    assertNotNull(json);
+    assertTrue(json.length() > 0);
+
+    // Parse the JSON and verify structure
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+    assertEquals("OutputNode-1", root.get("name").getAsString());
+    assertEquals("1", root.get("id").getAsString());
+    assertTrue(root.has("properties"));
+    assertTrue(root.has("children"));
+
+    // Verify child (LimitNode)
+    JsonObject limitJson = root.getAsJsonArray("children").get(0).getAsJsonObject();
+    assertEquals("LimitNode-2", limitJson.get("name").getAsString());
+
+    // Verify grandchild (FilterNode)
+    JsonObject filterJson = limitJson.getAsJsonArray("children").get(0).getAsJsonObject();
+    assertEquals("FilterNode-3", filterJson.get("name").getAsString());
+  }
+
+  @Test
+  public void testGetJsonLinesReturnsSingleElement() {
+    LimitNode limitNode =
+        new LimitNode(
+            new PlanNodeId("1"),
+            new LimitNode(new PlanNodeId("placeholder"), null, 0, Optional.empty()),
+            5,
+            Optional.empty());
+
+    List<String> lines = PlanGraphJsonPrinter.getJsonLines(limitNode);
+    assertEquals(1, lines.size());
+
+    // The single element should be valid JSON
+    JsonObject root = JsonParser.parseString(lines.get(0)).getAsJsonObject();
+    assertNotNull(root.get("name"));
+  }
+
+  @Test
+  public void testJsonIsValidFormat() {
+    LimitNode limitNode =
+        new LimitNode(
+            new PlanNodeId("1"),
+            new LimitNode(new PlanNodeId("placeholder"), null, 0, Optional.empty()),
+            42,
+            Optional.empty());
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(limitNode);
+
+    // Should parse without errors
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+    assertEquals("LimitNode-1", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    assertEquals("42", root.getAsJsonObject("properties").get("Count").getAsString());
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java
new file mode 100644
index 00000000000..56e204b38df
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.queryengine.plan.relational.sql;
+
+import org.apache.iotdb.db.protocol.session.IClientSession;
+import org.apache.iotdb.db.protocol.session.InternalClientSession;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Explain;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainAnalyze;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Statement;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.parser.SqlParser;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.ZoneId;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class ExplainFormatTest {
+
+  private SqlParser sqlParser;
+  private IClientSession clientSession;
+
+  @Before
+  public void setUp() {
+    sqlParser = new SqlParser();
+    clientSession = new InternalClientSession("testClient");
+    clientSession.setDatabaseName("testdb");
+  }
+
+  private Statement parseSQL(String sql) {
+    return sqlParser.createStatement(sql, ZoneId.systemDefault(), clientSession);
+  }
+
+  @Test
+  public void testExplainDefaultFormat() {
+    Statement stmt = parseSQL("EXPLAIN SELECT * FROM table1");
+    assertTrue(stmt instanceof Explain);
+    assertEquals(ExplainOutputFormat.GRAPHVIZ, ((Explain) stmt).getOutputFormat());
+  }
+
+  @Test
+  public void testExplainGraphvizFormat() {
+    Statement stmt = parseSQL("EXPLAIN (FORMAT GRAPHVIZ) SELECT * FROM table1");
+    assertTrue(stmt instanceof Explain);
+    assertEquals(ExplainOutputFormat.GRAPHVIZ, ((Explain) stmt).getOutputFormat());
+  }
+
+  @Test
+  public void testExplainJsonFormat() {
+    Statement stmt = parseSQL("EXPLAIN (FORMAT JSON) SELECT * FROM table1");
+    assertTrue(stmt instanceof Explain);
+    assertEquals(ExplainOutputFormat.JSON, ((Explain) stmt).getOutputFormat());
+  }
+
+  @Test
+  public void testExplainJsonFormatCaseInsensitive() {
+    Statement stmt = parseSQL("EXPLAIN (FORMAT json) SELECT * FROM table1");
+    assertTrue(stmt instanceof Explain);
+    assertEquals(ExplainOutputFormat.JSON, ((Explain) stmt).getOutputFormat());
+  }
+
+  @Test(expected = Exception.class)
+  public void testExplainInvalidFormat() {
+    parseSQL("EXPLAIN (FORMAT XML) SELECT * FROM table1");
+  }
+
+  @Test
+  public void testExplainAnalyzeDefaultFormat() {
+    Statement stmt = parseSQL("EXPLAIN ANALYZE SELECT * FROM table1");
+    assertTrue(stmt instanceof ExplainAnalyze);
+    assertEquals(ExplainOutputFormat.TEXT, ((ExplainAnalyze) stmt).getOutputFormat());
+  }
+
+  @Test
+  public void testExplainAnalyzeTextFormat() {
+    Statement stmt = parseSQL("EXPLAIN ANALYZE (FORMAT TEXT) SELECT * FROM table1");
+    assertTrue(stmt instanceof ExplainAnalyze);
+    assertEquals(ExplainOutputFormat.TEXT, ((ExplainAnalyze) stmt).getOutputFormat());
+  }
+
+  @Test
+  public void testExplainAnalyzeJsonFormat() {
+    Statement stmt = parseSQL("EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM table1");
+    assertTrue(stmt instanceof ExplainAnalyze);
+    assertEquals(ExplainOutputFormat.JSON, ((ExplainAnalyze) stmt).getOutputFormat());
+  }
+
+  @Test
+  public void testExplainAnalyzeVerboseJsonFormat() {
+    Statement stmt = parseSQL("EXPLAIN ANALYZE VERBOSE (FORMAT JSON) SELECT * FROM table1");
+    assertTrue(stmt instanceof ExplainAnalyze);
+    ExplainAnalyze ea = (ExplainAnalyze) stmt;
+    assertEquals(ExplainOutputFormat.JSON, ea.getOutputFormat());
+    assertTrue(ea.isVerbose());
+  }
+
+  @Test(expected = Exception.class)
+  public void testExplainAnalyzeInvalidFormat() {
+    parseSQL("EXPLAIN ANALYZE (FORMAT GRAPHVIZ) SELECT * FROM table1");
+  }
+
+  @Test(expected = Exception.class)
+  public void testExplainTextFormatInvalid() {
+    // TEXT is not valid for EXPLAIN (only GRAPHVIZ and JSON)
+    parseSQL("EXPLAIN (FORMAT TEXT) SELECT * FROM table1");
+  }
+}
diff --git a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4 b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
index 9b09d6ebad0..c7f1cf4b9bc 100644
--- a/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
+++ b/iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/grammar/sql/RelationalSql.g4
@@ -932,8 +932,8 @@ copyToStatementOption
 // ------------------------------------------- Query Statement ---------------------------------------------------------
 queryStatement
     : query                                                        #statementDefault
-    | EXPLAIN (query | executeStatement | executeImmediateStatement) #explain
-    | EXPLAIN ANALYZE VERBOSE? (query | executeStatement | executeImmediateStatement) #explainAnalyze
+    | EXPLAIN ('(' FORMAT identifier ')')? (query | executeStatement | executeImmediateStatement) #explain
+    | EXPLAIN ANALYZE VERBOSE? ('(' FORMAT identifier ')')? (query | executeStatement | executeImmediateStatement) #explainAnalyze
     ;
 
 query