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/06/11 08:06:56 UTC

(iotdb) branch master updated: Support EXPLAIN FORMAT JSON for Table Model (#17430)

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

JackieTien97 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git


The following commit(s) were added to refs/heads/master by this push:
     new a440038f1e5 Support EXPLAIN FORMAT JSON for Table Model (#17430)
a440038f1e5 is described below

commit a440038f1e54cbc58d7592b17f3ccc44fa2d0c98
Author: Jackie Tien <ja...@gmail.com>
AuthorDate: Thu Jun 11 16:06:49 2026 +0800

    Support EXPLAIN FORMAT JSON for Table Model (#17430)
---
 .codex/hooks.json                                  |  17 +
 .gitignore                                         |   2 +-
 .../iotdb/cli/it/ExplainJsonCliOutputIT.java       | 345 +++++++++
 .../it/insertquery/IoTDBInsertQueryIT.java         |   4 +-
 .../it/query/recent/IoTExplainJsonFormatIT.java    | 855 +++++++++++++++++++++
 .../java/org/apache/iotdb/cli/AbstractCli.java     |  46 +-
 .../java/org/apache/iotdb/cli/AbstractCliTest.java |  83 +-
 iotdb-core/datanode/pom.xml                        |   4 +
 .../db/queryengine/common/MPPQueryContext.java     |  10 +
 .../execution/operator/ExplainAnalyzeOperator.java |  70 +-
 .../iotdb/db/queryengine/plan/Coordinator.java     |  17 +-
 .../TableModelStatementMemorySourceVisitor.java    |  54 +-
 .../planner/DataNodeTableOperatorGenerator.java    |   7 +-
 .../planner/plan/node/PlanGraphJsonPrinter.java    | 225 ++++++
 .../relational/analyzer/StatementAnalyzer.java     |   2 +
 .../plan/relational/planner/CteMaterializer.java   |   1 +
 .../relational/planner/TableLogicalPlanner.java    |   3 +-
 .../distribute/TableDistributedPlanner.java        |  16 +-
 ...ithUncorrelatedScalarSubqueryReconstructor.java |   2 +
 .../planner/node/ExplainAnalyzeNode.java           |  39 +-
 .../optimizations/UnaliasSymbolReferences.java     |   3 +-
 .../plan/relational/sql/ast/Explain.java           |  28 +-
 .../plan/relational/sql/ast/ExplainAnalyze.java    |  37 +-
 .../relational/sql/ast/ExplainOutputFormat.java    |  35 +
 .../plan/relational/sql/parser/AstBuilder.java     |  35 +-
 .../FragmentInstanceStatisticsJsonDrawer.java      | 372 +++++++++
 .../operator/ExplainAnalyzeOperatorTest.java       |  96 +++
 ...TableModelStatementMemorySourceVisitorTest.java |  88 +++
 .../planner/node/PlanGraphJsonPrinterTest.java     | 573 ++++++++++++++
 .../relational/planner/CteMaterializerTest.java    |   1 +
 .../plan/relational/planner/CteSubqueryTest.java   |   1 +
 .../plan/relational/sql/ExplainFormatTest.java     | 186 +++++
 .../FragmentInstanceStatisticsJsonDrawerTest.java  | 594 ++++++++++++++
 .../db/relational/grammar/sql/RelationalSql.g4     |   4 +-
 34 files changed, 3812 insertions(+), 43 deletions(-)

diff --git a/.codex/hooks.json b/.codex/hooks.json
new file mode 100644
index 00000000000..d00d98ae0b8
--- /dev/null
+++ b/.codex/hooks.json
@@ -0,0 +1,17 @@
+{
+  "hooks": {
+    "PostToolUse": [
+      {
+        "matcher": "Write|Edit",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r f; echo \"$f\" | grep -q '\\.java$' && mvn spotless:apply -f \"$(git rev-parse --show-toplevel)/pom.xml\" -DspotlessFiles=\"$(echo \"$f\" | sed 's/[.[\\\\/^$|?*+(){}]/\\\\&/g')\" -q || true; }",
+            "timeout": 30,
+            "statusMessage": "Running spotless format..."
+          }
+        ]
+      }
+    ]
+  }
+}
diff --git a/.gitignore b/.gitignore
index a66cf2b1aa9..76e774d9286 100644
--- a/.gitignore
+++ b/.gitignore
@@ -138,4 +138,4 @@ iotdb-core/relational-grammar/src/main/antlr4/org/apache/iotdb/db/relational/gra
 .claude/settings.local.json
 .claude/todos/
 .claude/worktrees/
-.claude/scheduled_tasks.json
\ No newline at end of file
+.claude/scheduled_tasks.json
diff --git a/integration-test/src/test/java/org/apache/iotdb/cli/it/ExplainJsonCliOutputIT.java b/integration-test/src/test/java/org/apache/iotdb/cli/it/ExplainJsonCliOutputIT.java
new file mode 100644
index 00000000000..5ea757e9904
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/cli/it/ExplainJsonCliOutputIT.java
@@ -0,0 +1,345 @@
+/*
+ * 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.cli.it;
+
+import org.apache.iotdb.it.env.EnvFactory;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.TableLocalStandaloneIT;
+
+import com.google.gson.JsonParser;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Tests that EXPLAIN (FORMAT JSON) and EXPLAIN ANALYZE (FORMAT JSON) output raw JSON in CLI without
+ * table borders (no '|' or '+---' formatting), so users can directly copy the JSON for
+ * visualization.
+ */
+@RunWith(IoTDBTestRunner.class)
+@Category({TableLocalStandaloneIT.class})
+public class ExplainJsonCliOutputIT extends AbstractScriptIT {
+
+  private static String ip;
+  private static String port;
+  private static String sbinPath;
+  private static String libPath;
+  private static String homePath;
+
+  @BeforeClass
+  public static void setUp() throws Exception {
+    EnvFactory.getEnv().initClusterEnvironment();
+    ip = EnvFactory.getEnv().getIP();
+    port = EnvFactory.getEnv().getPort();
+    sbinPath = EnvFactory.getEnv().getSbinPath();
+    libPath = EnvFactory.getEnv().getLibPath();
+    homePath =
+        libPath.substring(0, libPath.lastIndexOf(File.separator + "lib" + File.separator + "*"));
+  }
+
+  @AfterClass
+  public static void tearDown() throws Exception {
+    EnvFactory.getEnv().cleanClusterEnvironment();
+  }
+
+  @Test
+  public void test() throws IOException {
+    String os = System.getProperty("os.name").toLowerCase();
+    if (os.startsWith("windows")) {
+      testOnWindows();
+    } else {
+      testOnUnix();
+    }
+  }
+
+  @Override
+  protected void testOnWindows() throws IOException {
+    // Setup test data
+    ProcessBuilder setupBuilder =
+        new ProcessBuilder(
+            "cmd.exe",
+            "/c",
+            sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
+            "-h",
+            ip,
+            "-p",
+            port,
+            "-sql_dialect",
+            "table",
+            "-e",
+            "CREATE DATABASE IF NOT EXISTS test_cli_json;"
+                + " USE test_cli_json;"
+                + " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
+                + " INSERT INTO t1 VALUES(1000, 'd1', 1.0)",
+            "&",
+            "exit",
+            "%^errorlevel%");
+    setupBuilder.environment().put("IOTDB_HOME", homePath);
+    testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
+
+    // Test EXPLAIN (FORMAT JSON) output has no table borders
+    // Use fully qualified table name (database.table) to avoid multi-statement quoting issues
+    // on Windows where cmd.exe mangles ';' separated SQL through the batch script
+    ProcessBuilder explainBuilder =
+        new ProcessBuilder(
+            "cmd.exe",
+            "/c",
+            sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
+            "-h",
+            ip,
+            "-p",
+            port,
+            "-sql_dialect",
+            "table",
+            "-e",
+            "EXPLAIN (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1",
+            "&",
+            "exit",
+            "%^errorlevel%");
+    explainBuilder.environment().put("IOTDB_HOME", homePath);
+    assertRawJsonOutput(explainBuilder, "distribution plan");
+
+    // Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
+    ProcessBuilder analyzeBuilder =
+        new ProcessBuilder(
+            "cmd.exe",
+            "/c",
+            sbinPath + File.separator + "windows" + File.separator + "start-cli.bat",
+            "-h",
+            ip,
+            "-p",
+            port,
+            "-sql_dialect",
+            "table",
+            "-e",
+            "EXPLAIN ANALYZE (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1",
+            "&",
+            "exit",
+            "%^errorlevel%");
+    analyzeBuilder.environment().put("IOTDB_HOME", homePath);
+    assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
+  }
+
+  @Override
+  protected void testOnUnix() throws IOException {
+    // Setup test data
+    ProcessBuilder setupBuilder =
+        new ProcessBuilder(
+            "bash",
+            sbinPath + File.separator + "start-cli.sh",
+            "-h",
+            ip,
+            "-p",
+            port,
+            "-sql_dialect",
+            "table",
+            "-e",
+            "\"CREATE DATABASE IF NOT EXISTS test_cli_json;"
+                + " USE test_cli_json;"
+                + " CREATE TABLE IF NOT EXISTS t1(id STRING TAG, v FLOAT FIELD);"
+                + " INSERT INTO t1 VALUES(1000, 'd1', 1.0)\"");
+    setupBuilder.environment().put("IOTDB_HOME", homePath);
+    testOutput(setupBuilder, new String[] {"Msg: The statement is executed successfully."}, 0);
+
+    // Test EXPLAIN (FORMAT JSON) output has no table borders
+    // Use fully qualified table name (database.table) for consistency with Windows tests
+    ProcessBuilder explainBuilder =
+        new ProcessBuilder(
+            "bash",
+            sbinPath + File.separator + "start-cli.sh",
+            "-h",
+            ip,
+            "-p",
+            port,
+            "-sql_dialect",
+            "table",
+            "-e",
+            "\"EXPLAIN (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1\"");
+    explainBuilder.environment().put("IOTDB_HOME", homePath);
+    assertRawJsonOutput(explainBuilder, "distribution plan");
+
+    // Test EXPLAIN ANALYZE (FORMAT JSON) output has no table borders
+    ProcessBuilder analyzeBuilder =
+        new ProcessBuilder(
+            "bash",
+            sbinPath + File.separator + "start-cli.sh",
+            "-h",
+            ip,
+            "-p",
+            port,
+            "-sql_dialect",
+            "table",
+            "-e",
+            "\"EXPLAIN ANALYZE (FORMAT JSON) SELECT time,id,v FROM test_cli_json.t1\"");
+    analyzeBuilder.environment().put("IOTDB_HOME", homePath);
+    assertRawJsonOutput(analyzeBuilder, "Explain Analyze");
+  }
+
+  /**
+   * Formats all output lines into a single string for use in assertion messages. Each line is
+   * prefixed with its index for easy identification.
+   */
+  private String formatOutputForDiag(List<String> outputList) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("Total lines: ").append(outputList.size()).append("\n");
+    for (int i = 0; i < outputList.size(); i++) {
+      sb.append(String.format("  [%3d] |%s|%n", i, outputList.get(i)));
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Collects all output lines from a process, then verifies: 1. The column header is printed before
+   * JSON content 2. No JSON content line has table border characters ('|' prefix or '+---'
+   * separator) 3. The combined JSON content is valid JSON
+   */
+  private void assertRawJsonOutput(ProcessBuilder builder, String expectedHeader)
+      throws IOException {
+    builder.redirectErrorStream(true);
+    Process p = builder.start();
+    BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
+    String line;
+    List<String> outputList = new ArrayList<>();
+    while (true) {
+      line = r.readLine();
+      if (line == null) {
+        break;
+      } else {
+        outputList.add(line);
+      }
+    }
+    r.close();
+    p.destroy();
+
+    System.out.println("Process output:");
+    for (String s : outputList) {
+      System.out.println(s);
+    }
+
+    String fullOutput = formatOutputForDiag(outputList);
+
+    // Find the JSON content region: from the first line starting with '{' to the corresponding '}'
+    int jsonStart = -1;
+    int jsonEnd = -1;
+    for (int i = 0; i < outputList.size(); i++) {
+      String trimmed = outputList.get(i).trim();
+      if (jsonStart == -1 && trimmed.startsWith("{")) {
+        jsonStart = i;
+      }
+      // The last line that is just '}' marks the end of JSON
+      if (jsonStart != -1 && trimmed.equals("}")) {
+        jsonEnd = i;
+      }
+    }
+
+    assertTrue("Should find JSON start '{'. Full process output:\n" + fullOutput, jsonStart >= 0);
+    assertTrue(
+        "Should find JSON end '}'. jsonStart="
+            + jsonStart
+            + ". Full process output:\n"
+            + fullOutput,
+        jsonEnd >= jsonStart);
+
+    // Verify the column header with table border appears before JSON content
+    // Expected format: +---+  |header|  +---+  {json...}  +---+
+    assertTrue(
+        "Header border should appear before JSON content. jsonStart="
+            + jsonStart
+            + ". Full process output:\n"
+            + fullOutput,
+        jsonStart >= 3);
+    assertTrue(
+        "Header border line should be present at line "
+            + (jsonStart - 3)
+            + ", got: '"
+            + outputList.get(jsonStart - 3).trim()
+            + "'. Full process output:\n"
+            + fullOutput,
+        outputList.get(jsonStart - 3).trim().matches("\\+[-+]+\\+"));
+    assertTrue(
+        "Column header '"
+            + expectedHeader
+            + "' should be present at line "
+            + (jsonStart - 2)
+            + ", got: '"
+            + outputList.get(jsonStart - 2)
+            + "'. Full process output:\n"
+            + fullOutput,
+        outputList.get(jsonStart - 2).contains(expectedHeader));
+    assertTrue(
+        "Header border line should be present at line "
+            + (jsonStart - 1)
+            + ", got: '"
+            + outputList.get(jsonStart - 1).trim()
+            + "'. Full process output:\n"
+            + fullOutput,
+        outputList.get(jsonStart - 1).trim().matches("\\+[-+]+\\+"));
+
+    // Verify JSON content lines do not have '|' borders
+    for (int i = jsonStart; i <= jsonEnd; i++) {
+      String s = outputList.get(i).trim();
+      assertFalse(
+          "JSON line " + i + " should not start with '|', but found: " + s, s.startsWith("|"));
+    }
+
+    // Concatenate JSON lines and verify it's valid JSON
+    StringBuilder jsonBuilder = new StringBuilder();
+    for (int i = jsonStart; i <= jsonEnd; i++) {
+      jsonBuilder.append(outputList.get(i));
+    }
+    String jsonStr = jsonBuilder.toString();
+    try {
+      JsonParser.parseString(jsonStr).getAsJsonObject();
+    } catch (Exception e) {
+      fail(
+          "Output should be valid JSON, but got parse error: "
+              + e.getMessage()
+              + "\nJSON string: "
+              + jsonStr
+              + "\nFull process output:\n"
+              + fullOutput);
+    }
+
+    // Verify process exit code
+    while (p.isAlive()) {
+      try {
+        Thread.sleep(100);
+      } catch (InterruptedException e) {
+        e.printStackTrace();
+        fail();
+      }
+    }
+    assertEquals("Process exit code should be 0. Full output:\n" + fullOutput, 0, p.exitValue());
+  }
+}
diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java
index b67fe732f16..174b2d4711a 100644
--- a/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/insertquery/IoTDBInsertQueryIT.java
@@ -460,7 +460,7 @@ public class IoTDBInsertQueryIT {
           e.getMessage(),
           e.getMessage()
               .contains(
-                  "700: line 1:9: mismatched input 'INSERT'. Expecting: 'ANALYZE', 'EXECUTE', <query>"));
+                  "700: line 1:9: mismatched input 'INSERT'. Expecting: '(', 'ANALYZE', 'EXECUTE', <query>"));
     }
 
     try {
@@ -472,7 +472,7 @@ public class IoTDBInsertQueryIT {
           e.getMessage(),
           e.getMessage()
               .contains(
-                  "700: line 1:17: mismatched input 'INSERT'. Expecting: 'EXECUTE', 'VERBOSE', <query>"));
+                  "700: line 1:17: mismatched input 'INSERT'. Expecting: '(', 'EXECUTE', 'VERBOSE', <query>"));
     }
   }
 
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..3e640c200e8
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/query/recent/IoTExplainJsonFormatIT.java
@@ -0,0 +1,855 @@
+/*
+ *
+ *  * 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 org.apache.iotdb.itbase.runtime.ClusterTestConnection;
+
+import com.google.gson.JsonArray;
+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)
+        .setMemtableSizeThreshold(10000)
+        .setMaxRowsInCteBuffer(100);
+    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)");
+      // Insert data across multiple time partitions (partitionInterval=1000)
+      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(3000, 'd1', 150.0)");
+      statement.execute("INSERT INTO testtb VALUES(1000, 'd2', 300.0)");
+      statement.execute("INSERT INTO testtb VALUES(2000, 'd2', 250.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() {
+    // Expected output (single row, single JSON object representing the distributed plan tree):
+    // {
+    //   "name": "OutputNode-<id>",
+    //   "id": "<id>",
+    //   "properties": {
+    //     "OutputColumns": ["time", "deviceid", "voltage"],
+    //     "OutputSymbols": ["time", "deviceid", "voltage"]
+    //   },
+    //   "children": [
+    //     {
+    //       "name": "CollectNode-<id>",
+    //       "id": "<id>",
+    //       "children": [
+    //         {
+    //           "name": "ExchangeNode-<id>",
+    //           "id": "<id>",
+    //           "children": [
+    //             {
+    //               "name": "DeviceTableScanNode-<id>",
+    //               "id": "<id>",
+    //               "properties": {
+    //                 "QualifiedTableName": "testdb_json.testtb",
+    //                 "OutputSymbols": ["time", "deviceid", "voltage"],
+    //                 "DeviceNumber": "1",
+    //                 "ScanOrder": "ASC",
+    //                 "PushDownOffset": "0",
+    //                 "PushDownLimit": "0",
+    //                 "PushDownLimitToEachDevice": "false",
+    //                 "RegionId": "<regionId>"
+    //               }
+    //             }
+    //           ]
+    //         },
+    //         {
+    //           "name": "ExchangeNode-<id>",
+    //           "id": "<id>",
+    //           "children": [
+    //             {
+    //               "name": "DeviceTableScanNode-<id>",
+    //               "id": "<id>",
+    //               "properties": { ... }
+    //             }
+    //           ]
+    //         }
+    //       ]
+    //     }
+    //   ]
+    // }
+    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 testExplainJsonWithExecuteKeepsFormat() {
+    // Expected output for both EXECUTE and EXECUTE IMMEDIATE (single row, single JSON object
+    // representing the resolved query's distributed plan tree; the outer FORMAT JSON must be
+    // preserved when the prepared/immediate SQL is unwrapped):
+    // {
+    //   "name": "OutputNode-<id>",
+    //   "id": "<id>",
+    //   "properties": {
+    //     "OutputColumns": ["time", "deviceid", "voltage"],
+    //     "OutputSymbols": ["time", "deviceid", "voltage"]
+    //   },
+    //   "children": [
+    //     {
+    //       "name": "CollectNode-<id>",
+    //       "children": [
+    //         {
+    //           "name": "ExchangeNode-<id>",
+    //           "children": [
+    //             {
+    //               "name": "DeviceTableScanNode-<id>",
+    //               "properties": {
+    //                 "QualifiedTableName": "testdb_json.testtb",
+    //                 "PushDownPredicate": "(\"deviceid\" = 'd1')",
+    //                 ...
+    //               }
+    //             }
+    //           ]
+    //         }
+    //       ]
+    //     }
+    //   ]
+    // }
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      Statement sessionStatement = getSessionScopedStatement(conn, statement);
+      try {
+        sessionStatement.execute("USE " + DATABASE_NAME);
+        sessionStatement.execute(
+            "PREPARE explain_json_stmt FROM SELECT * FROM testtb WHERE deviceid = ?");
+
+        ResultSet executeResultSet =
+            sessionStatement.executeQuery(
+                "EXPLAIN (FORMAT JSON) EXECUTE explain_json_stmt USING 'd1'");
+        Assert.assertTrue(executeResultSet.next());
+        JsonObject executeJson =
+            JsonParser.parseString(executeResultSet.getString(1)).getAsJsonObject();
+        Assert.assertTrue("EXECUTE explain JSON should have 'name' field", executeJson.has("name"));
+        executeResultSet.close();
+
+        ResultSet immediateResultSet =
+            sessionStatement.executeQuery(
+                "EXPLAIN (FORMAT JSON) EXECUTE IMMEDIATE "
+                    + "'SELECT * FROM testtb WHERE deviceid = ?' USING 'd1'");
+        Assert.assertTrue(immediateResultSet.next());
+        JsonObject immediateJson =
+            JsonParser.parseString(immediateResultSet.getString(1)).getAsJsonObject();
+        Assert.assertTrue(
+            "EXECUTE IMMEDIATE explain JSON should have 'name' field", immediateJson.has("name"));
+        immediateResultSet.close();
+
+        sessionStatement.execute("DEALLOCATE PREPARE explain_json_stmt");
+      } finally {
+        closeSessionScopedStatement(sessionStatement, statement);
+      }
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeJsonFormat() {
+    // Expected output (single row, single JSON object with plan statistics + fragment instances):
+    // {
+    //   "planStatistics": {
+    //     "analyzeCostMs": <ms>,
+    //     "fetchPartitionCostMs": <ms>,
+    //     "fetchSchemaCostMs": <ms>,
+    //     "logicalPlanCostMs": <ms>,
+    //     "logicalOptimizationCostMs": <ms>,
+    //     "distributionPlanCostMs": <ms>,
+    //     "dispatchCostMs": <ms>
+    //   },
+    //   "fragmentInstancesCount": 3,
+    //   "fragmentInstances": [
+    //     {
+    //       "id": "<queryId>.<fragmentId>.<instanceId>",
+    //       "ip": "127.0.0.1:<port>",
+    //       "dataRegion": "virtual_data_region",
+    //       "state": "FINISHED",
+    //       "totalWallTimeMs": <ms>,
+    //       "initDataQuerySourceCostMs": <ms>,
+    //       "seqFileUnclosed": 0, "seqFileClosed": 0,
+    //       "unseqFileUnclosed": 0, "unseqFileClosed": 0,
+    //       "readyQueuedTimeMs": <ms>,
+    //       "blockQueuedTimeMs": <ms>,
+    //       "queryStatistics": {
+    //         "timeSeriesIndexFilteredRows": 0,
+    //         "chunkIndexFilteredRows": 0,
+    //         "pageIndexFilteredRows": 0
+    //       },
+    //       "operators": {
+    //         "planNodeId": "<id>",
+    //         "nodeType": "IdentitySinkNode",
+    //         "operatorType": "IdentitySinkOperator",
+    //         "cpuTimeMs": <ms>,
+    //         "outputRows": 5,
+    //         "hasNextCalledCount": 5,
+    //         "nextCalledCount": 4,
+    //         "estimatedMemorySize": 1024,
+    //         "specifiedInfo": { "DownStreamPlanNodeId": "<id>" },
+    //         "children": [
+    //           {
+    //             "planNodeId": "<id>",
+    //             "nodeType": "CollectNode",
+    //             "operatorType": "CollectOperator",
+    //             ...
+    //             "children": [
+    //               { "planNodeId": "<id>", "nodeType": "ExchangeNode", ... },
+    //               { "planNodeId": "<id>", "nodeType": "ExchangeNode", ... }
+    //             ]
+    //           }
+    //         ]
+    //       }
+    //     },
+    //     {
+    //       "id": "...", "dataRegion": "4", "state": "FINISHED",
+    //       ...
+    //       "operators": {
+    //         "nodeType": "IdentitySinkNode", ...
+    //         "children": [
+    //           { "nodeType": "DeviceTableScanNode", "operatorType": "TableScanOperator", ... }
+    //         ]
+    //       }
+    //     },
+    //     {
+    //       "id": "...", "dataRegion": "3", "state": "FINISHED",
+    //       ...
+    //       "operators": { ... }
+    //     }
+    //   ]
+    // }
+    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 testExplainAnalyzeJsonWithExecuteKeepsFormat() {
+    // Expected output for both EXECUTE and EXECUTE IMMEDIATE (single row, single JSON object
+    // with plan statistics + fragment instances; the outer FORMAT JSON must be preserved when the
+    // prepared/immediate SQL is unwrapped):
+    // {
+    //   "planStatistics": {
+    //     "analyzeCostMs": <ms>,
+    //     "fetchPartitionCostMs": <ms>,
+    //     "fetchSchemaCostMs": <ms>,
+    //     "logicalPlanCostMs": <ms>,
+    //     "logicalOptimizationCostMs": <ms>,
+    //     "distributionPlanCostMs": <ms>,
+    //     "dispatchCostMs": <ms>
+    //   },
+    //   "fragmentInstancesCount": <count>,
+    //   "fragmentInstances": [
+    //     {
+    //       "id": "<queryId>.<fragmentId>.<instanceId>",
+    //       "ip": "127.0.0.1:<port>",
+    //       "dataRegion": "<region>",
+    //       "state": "FINISHED",
+    //       "queryStatistics": { ... },
+    //       "operators": { ... }
+    //     }
+    //   ]
+    // }
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      Statement sessionStatement = getSessionScopedStatement(conn, statement);
+      try {
+        sessionStatement.execute("USE " + DATABASE_NAME);
+        sessionStatement.execute(
+            "PREPARE explain_analyze_json_stmt FROM SELECT * FROM testtb WHERE deviceid = ?");
+
+        ResultSet executeResultSet =
+            sessionStatement.executeQuery(
+                "EXPLAIN ANALYZE (FORMAT JSON) EXECUTE explain_analyze_json_stmt USING 'd1'");
+        Assert.assertTrue(executeResultSet.next());
+        JsonObject executeJson =
+            JsonParser.parseString(executeResultSet.getString(1)).getAsJsonObject();
+        Assert.assertTrue(
+            "EXECUTE explain analyze JSON should have 'planStatistics' field",
+            executeJson.has("planStatistics"));
+        Assert.assertTrue(
+            "EXECUTE explain analyze JSON should have 'fragmentInstances' field",
+            executeJson.has("fragmentInstances"));
+        executeResultSet.close();
+
+        ResultSet immediateResultSet =
+            sessionStatement.executeQuery(
+                "EXPLAIN ANALYZE (FORMAT JSON) EXECUTE IMMEDIATE "
+                    + "'SELECT * FROM testtb WHERE deviceid = ?' USING 'd1'");
+        Assert.assertTrue(immediateResultSet.next());
+        JsonObject immediateJson =
+            JsonParser.parseString(immediateResultSet.getString(1)).getAsJsonObject();
+        Assert.assertTrue(
+            "EXECUTE IMMEDIATE explain analyze JSON should have 'planStatistics' field",
+            immediateJson.has("planStatistics"));
+        Assert.assertTrue(
+            "EXECUTE IMMEDIATE explain analyze JSON should have 'fragmentInstances' field",
+            immediateJson.has("fragmentInstances"));
+        immediateResultSet.close();
+
+        sessionStatement.execute("DEALLOCATE PREPARE explain_analyze_json_stmt");
+      } finally {
+        closeSessionScopedStatement(sessionStatement, statement);
+      }
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeVerboseJsonFormat() {
+    // Expected output (same structure as testExplainAnalyzeJsonFormat, but queryStatistics
+    // includes verbose fields like bloom filter, metadata, chunk reader, and page decoder stats):
+    // {
+    //   "planStatistics": { ... },
+    //   "fragmentInstancesCount": 3,
+    //   "fragmentInstances": [
+    //     {
+    //       "id": "...", "dataRegion": "virtual_data_region", ...
+    //       "queryStatistics": {
+    //         "loadBloomFilterFromCacheCount": 0,
+    //         "loadBloomFilterFromDiskCount": 0,
+    //         "loadBloomFilterActualIOSize": 0,
+    //         "loadBloomFilterTimeMs": 0.0,
+    //         "loadTimeSeriesMetadataFromCacheCount": 0,
+    //         "loadTimeSeriesMetadataFromDiskCount": 0,
+    //         "loadTimeSeriesMetadataActualIOSize": 0,
+    //         "loadChunkFromCacheCount": 0,
+    //         "loadChunkFromDiskCount": 0,
+    //         "loadChunkActualIOSize": 0,
+    //         "timeSeriesIndexFilteredRows": 0,
+    //         "chunkIndexFilteredRows": 0,
+    //         "pageIndexFilteredRows": 0,
+    //         "rowScanFilteredRows": 0
+    //       },
+    //       "operators": { ... }
+    //     },
+    //     {
+    //       "id": "...", "dataRegion": "4", ...
+    //       "queryStatistics": {
+    //         ... (same as above, plus non-zero fields like:)
+    //         "loadTimeSeriesMetadataAlignedMemSeqCount": 2,
+    //         "loadTimeSeriesMetadataAlignedMemSeqTimeMs": <ms>,
+    //         "pageReadersDecodeAlignedMemCount": 2,
+    //         "pageReadersDecodeAlignedMemTimeMs": <ms>,
+    //         ...
+    //       },
+    //       "operators": { ... }
+    //     },
+    //     { "id": "...", "dataRegion": "3", ... }
+    //   ]
+    // }
+    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
+  public void testExplainInvalidFormat() {
+    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);
+      fail("Expected SQLException for invalid format");
+    } catch (SQLException e) {
+      Assert.assertTrue(
+          "Error message should mention the invalid format",
+          e.getMessage().toUpperCase().contains("FORMAT")
+              || e.getMessage().toUpperCase().contains("XML"));
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeJsonMultipleFragmentInstances() {
+    // Expected output (same structure as testExplainAnalyzeJsonFormat):
+    // {
+    //   "planStatistics": { ... },
+    //   "fragmentInstancesCount": 3,       // >= 2 due to multi-partition data
+    //   "fragmentInstances": [
+    //     { "id": "...", "dataRegion": "virtual_data_region", "state": "FINISHED", ... },
+    //     { "id": "...", "dataRegion": "4", "state": "FINISHED", ... },
+    //     { "id": "...", "dataRegion": "3", "state": "FINISHED", ... }
+    //   ]
+    // }
+    // Each fragment instance must have "id", "state", and "dataRegion" fields.
+    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);
+
+      Assert.assertTrue(resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+      // Verify fragmentInstancesCount matches the size of fragmentInstances array
+      int declaredCount = root.get("fragmentInstancesCount").getAsInt();
+      JsonArray fragmentInstances = root.getAsJsonArray("fragmentInstances");
+      Assert.assertNotNull("fragmentInstances array should be present", fragmentInstances);
+      Assert.assertEquals(
+          "fragmentInstancesCount should match fragmentInstances array size",
+          declaredCount,
+          fragmentInstances.size());
+      Assert.assertTrue(
+          "Should have at least 2 fragment instances for multi-partition data", declaredCount >= 2);
+
+      // Verify each fragment instance has required fields
+      for (int i = 0; i < fragmentInstances.size(); i++) {
+        JsonObject fi = fragmentInstances.get(i).getAsJsonObject();
+        Assert.assertTrue("Fragment instance should have 'id'", fi.has("id"));
+        Assert.assertTrue("Fragment instance should have 'state'", fi.has("state"));
+        Assert.assertTrue("Fragment instance should have 'dataRegion'", fi.has("dataRegion"));
+      }
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainJsonWithCte() {
+    // Expected output (when CTE is present, the JSON wraps cteQueries + mainQuery):
+    // {
+    //   "cteQueries": [
+    //     {
+    //       "name": "cte1",
+    //       "plan": <plan node for CTE query>
+    //     }
+    //   ],
+    //   "mainQuery": {
+    //     "name": "OutputNode-<id>",
+    //     "id": "<id>",
+    //     "properties": {
+    //       "OutputColumns": ["time", "deviceid", "voltage"],
+    //       "OutputSymbols": ["time", "deviceid", "voltage"]
+    //     },
+    //     "children": [
+    //       {
+    //         "name": "ProjectNode-<id>", ...
+    //         "children": [
+    //           {
+    //             "name": "FilterNode-<id>", ...
+    //             "children": [
+    //               {
+    //                 "name": "SemiJoinNode-<id>", ...
+    //                 "children": [
+    //                   { "name": "ExchangeNode-<id>", ... },  // main table scan branch
+    //                   { "name": "ExchangeNode-<id>", ... }   // CTE scan branch
+    //                 ]
+    //               }
+    //             ]
+    //           }
+    //         ]
+    //       }
+    //     ]
+    //   }
+    // }
+    try (Connection conn = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT);
+        Statement statement = conn.createStatement()) {
+      statement.execute("USE " + DATABASE_NAME);
+      statement.execute(
+          "CREATE TABLE IF NOT EXISTS cte_tb(deviceid STRING TAG, voltage FLOAT FIELD)");
+      statement.execute("INSERT INTO cte_tb VALUES(1000, 'd1', 50.0)");
+
+      String sql =
+          "EXPLAIN (FORMAT JSON) WITH cte1 AS MATERIALIZED (SELECT * FROM cte_tb) "
+              + "SELECT * FROM testtb WHERE testtb.deviceid IN (SELECT deviceid FROM cte1)";
+      ResultSet resultSet = statement.executeQuery(sql);
+
+      Assert.assertTrue(resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+      // When CTEs are present, the JSON should have cteQueries and mainQuery
+      Assert.assertTrue("JSON with CTE should have 'cteQueries' field", root.has("cteQueries"));
+      Assert.assertTrue("JSON with CTE should have 'mainQuery' field", root.has("mainQuery"));
+
+      JsonArray cteQueries = root.getAsJsonArray("cteQueries");
+      Assert.assertEquals("Should have exactly 1 CTE query", 1, cteQueries.size());
+
+      JsonObject cte = cteQueries.get(0).getAsJsonObject();
+      Assert.assertTrue("CTE should have 'name' field", cte.has("name"));
+      Assert.assertEquals("cte1", cte.get("name").getAsString());
+      Assert.assertTrue("CTE should have 'plan' field", cte.has("plan"));
+      Assert.assertTrue("CTE plan should be a JSON object", cte.get("plan").isJsonObject());
+      Assert.assertTrue(
+          "CTE plan should have 'name' field", cte.getAsJsonObject("plan").has("name"));
+
+      // The main query plan should be a JSON object with 'name' field (plan node)
+      JsonObject mainQuery = root.getAsJsonObject("mainQuery");
+      Assert.assertTrue("Main query plan should have 'name' field", mainQuery.has("name"));
+
+      statement.execute("DROP TABLE IF EXISTS cte_tb");
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainJsonWithScalarSubquery() {
+    // Expected output (scalar subquery is inlined during optimization, so the plan is a simple
+    // tree with the constant predicate pushed down to DeviceTableScanNode):
+    // {
+    //   "name": "OutputNode-<id>",
+    //   "id": "<id>",
+    //   "properties": {
+    //     "OutputColumns": ["time", "deviceid", "voltage"],
+    //     "OutputSymbols": ["time", "deviceid", "voltage"]
+    //   },
+    //   "children": [
+    //     {
+    //       "name": "CollectNode-<id>",
+    //       "id": "<id>",
+    //       "children": [
+    //         {
+    //           "name": "ExchangeNode-<id>", ...
+    //           "children": [
+    //             {
+    //               "name": "DeviceTableScanNode-<id>",
+    //               "properties": {
+    //                 "QualifiedTableName": "testdb_json.testtb",
+    //                 "PushDownPredicate": "(\"voltage\" > 2E2)",
+    //                 ...
+    //               }
+    //             }
+    //           ]
+    //         },
+    //         {
+    //           "name": "ExchangeNode-<id>", ...
+    //           "children": [
+    //             { "name": "DeviceTableScanNode-<id>", ... }
+    //           ]
+    //         }
+    //       ]
+    //     }
+    //   ]
+    // }
+    String sql =
+        "EXPLAIN (FORMAT JSON) SELECT * FROM testtb "
+            + "WHERE voltage > (SELECT avg(voltage) 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(resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+      // Verify it's a valid plan tree with children (subquery creates a more complex plan)
+      Assert.assertTrue("JSON should have 'name' field", root.has("name"));
+      Assert.assertTrue("JSON should have 'id' field", root.has("id"));
+      Assert.assertTrue("Plan with scalar subquery should have 'children'", root.has("children"));
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  @Test
+  public void testExplainAnalyzeJsonWithScalarSubquery() {
+    // Expected output (same EXPLAIN ANALYZE JSON structure, but the scalar subquery is resolved
+    // at planning time, so the executed plan only scans with a constant predicate):
+    // {
+    //   "planStatistics": {
+    //     "analyzeCostMs": <ms>,
+    //     "fetchPartitionCostMs": <ms>,
+    //     "fetchSchemaCostMs": <ms>,
+    //     "logicalPlanCostMs": <ms>,     // higher than simple query due to subquery planning
+    //     "logicalOptimizationCostMs": <ms>,
+    //     "distributionPlanCostMs": <ms>,
+    //     "dispatchCostMs": <ms>
+    //   },
+    //   "fragmentInstancesCount": 3,
+    //   "fragmentInstances": [
+    //     {
+    //       "id": "...", "dataRegion": "virtual_data_region", "state": "FINISHED",
+    //       ...
+    //       "operators": {
+    //         "nodeType": "IdentitySinkNode", ...
+    //         "children": [
+    //           {
+    //             "nodeType": "CollectNode", ...
+    //             "children": [
+    //               { "nodeType": "ExchangeNode", "outputRows": 2, ... },
+    //               { "nodeType": "ExchangeNode", "outputRows": 0, ... }
+    //             ]
+    //           }
+    //         ]
+    //       }
+    //     },
+    //     {
+    //       "dataRegion": "4", ...
+    //       "operators": {
+    //         "nodeType": "IdentitySinkNode", ...
+    //         "children": [
+    //           { "nodeType": "DeviceTableScanNode", "operatorType": "TableScanOperator", ... }
+    //         ]
+    //       }
+    //     },
+    //     {
+    //       "dataRegion": "3", ...
+    //       "operators": {
+    //         "nodeType": "IdentitySinkNode", ...
+    //         "children": [
+    //           { "nodeType": "DeviceTableScanNode", "outputRows": 0, ... }
+    //         ]
+    //       }
+    //     }
+    //   ]
+    // }
+    String sql =
+        "EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM testtb "
+            + "WHERE voltage > (SELECT avg(voltage) 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(resultSet.next());
+      String jsonStr = resultSet.getString(1);
+      JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
+
+      Assert.assertTrue(root.has("planStatistics"));
+      Assert.assertTrue(root.has("fragmentInstances"));
+      Assert.assertTrue(root.has("fragmentInstancesCount"));
+
+      int declaredCount = root.get("fragmentInstancesCount").getAsInt();
+      JsonArray fragmentInstances = root.getAsJsonArray("fragmentInstances");
+      Assert.assertEquals(declaredCount, fragmentInstances.size());
+
+      resultSet.close();
+    } catch (SQLException e) {
+      fail(e.getMessage());
+    }
+  }
+
+  private static Statement getSessionScopedStatement(Connection connection, Statement statement)
+      throws SQLException {
+    if (connection instanceof ClusterTestConnection) {
+      return ((ClusterTestConnection) connection)
+          .writeConnection
+          .getUnderlyingConnection()
+          .createStatement();
+    }
+    return statement;
+  }
+
+  private static void closeSessionScopedStatement(Statement sessionStatement, Statement statement)
+      throws SQLException {
+    if (sessionStatement != statement) {
+      sessionStatement.close();
+    }
+  }
+}
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java
index 22ecdc3a40e..3d8535aac3c 100644
--- a/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/cli/AbstractCli.java
@@ -566,7 +566,11 @@ public abstract class AbstractCli {
           List<Integer> maxSizeList = new ArrayList<>(columnLength);
           List<List<String>> lists =
               cacheResult(ctx, resultSet, maxSizeList, columnLength, resultSetMetaData, zoneId);
-          output(ctx, lists, maxSizeList);
+          if (isJsonExplainResult(lists)) {
+            outputRawJson(ctx, lists, maxSizeList);
+          } else {
+            output(ctx, lists, maxSizeList);
+          }
           ctx.getPrinter().println(String.format("It costs %.3fs", costTime / 1000.0));
           while (!isReachEnd) {
             if (continuePrint) {
@@ -816,6 +820,46 @@ public abstract class AbstractCli {
     return lists;
   }
 
+  private static final String COLUMN_DISTRIBUTION_PLAN = "distribution plan";
+  private static final String COLUMN_EXPLAIN_ANALYZE = "Explain Analyze";
+
+  private static boolean isJsonExplainResult(List<List<String>> lists) {
+    if (lists.size() != 1 || lists.get(0).size() < 2) {
+      return false;
+    }
+    String columnName = lists.get(0).get(0);
+    if (!COLUMN_DISTRIBUTION_PLAN.equalsIgnoreCase(columnName)
+        && !COLUMN_EXPLAIN_ANALYZE.equalsIgnoreCase(columnName)) {
+      return false;
+    }
+    String value = lists.get(0).get(1).trim();
+    return value.startsWith("{") || value.startsWith("[");
+  }
+
+  private static void outputRawJson(
+      CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) {
+    // Use header text length for border width instead of the full content length
+    String header = lists.get(0).get(0);
+    int headerLen = header.length() + ctx.getPrinter().computeHANCount(header);
+    List<Integer> headerSizeList = new ArrayList<>(1);
+    headerSizeList.add(headerLen);
+    // Print header with table border
+    ctx.getPrinter().printBlockLine(headerSizeList);
+    ctx.getPrinter().printRow(lists, 0, headerSizeList);
+    ctx.getPrinter().printBlockLine(headerSizeList);
+    // Print JSON content without '|' borders
+    for (int i = 1; i < lists.get(0).size(); i++) {
+      ctx.getPrinter().println(lists.get(0).get(i));
+    }
+    ctx.getPrinter().printBlockLine(headerSizeList);
+    if (isReachEnd) {
+      lineCount += lists.get(0).size() - 1;
+      ctx.getPrinter().printCount(lineCount);
+    } else {
+      lineCount += maxPrintRowCount;
+    }
+  }
+
   private static void output(CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) {
     ctx.getPrinter().printBlockLine(maxSizeList);
     ctx.getPrinter().printRow(lists, 0, maxSizeList);
diff --git a/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java b/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java
index 635676ce2af..01644d58412 100644
--- a/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java
+++ b/iotdb-client/cli/src/test/java/org/apache/iotdb/cli/AbstractCliTest.java
@@ -39,7 +39,18 @@ import org.mockito.MockitoAnnotations;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.mockito.Mockito.when;
 
@@ -57,7 +68,10 @@ public class AbstractCliTest {
   }
 
   @After
-  public void tearDown() {}
+  public void tearDown() throws Exception {
+    setStaticField("lineCount", 0);
+    setStaticField("isReachEnd", false);
+  }
 
   @Test
   public void testInit() {
@@ -199,4 +213,71 @@ public class AbstractCliTest {
         AbstractCli.handleInputCmd(
             ctx, String.format("%s=111", AbstractCli.SET_FETCH_SIZE), connection));
   }
+
+  @Test
+  public void testJsonExplainResultDetection() throws Exception {
+    assertFalse(isJsonExplainResult(Collections.emptyList()));
+    assertFalse(isJsonExplainResult(Collections.singletonList(Collections.singletonList("Time"))));
+    assertFalse(
+        isJsonExplainResult(
+            Arrays.asList(
+                Arrays.asList("distribution plan", "{}"), Arrays.asList("extra", "value"))));
+    assertFalse(isJsonExplainResult(column("Time", "{}")));
+    assertFalse(isJsonExplainResult(column("distribution plan", "OutputNode-1")));
+
+    assertTrue(isJsonExplainResult(column("distribution plan", "  {\"name\":\"OutputNode-1\"}")));
+    assertTrue(isJsonExplainResult(column("Explain Analyze", "[{\"id\":\"fragment\"}]")));
+    assertTrue(isJsonExplainResult(column("explain analyze", "\n{\"planStatistics\":{}}")));
+  }
+
+  @Test
+  public void testOutputRawJsonKeepsJsonContentUnframed() throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    PrintStream printer = new PrintStream(out, true, StandardCharsets.UTF_8.name());
+    CliContext ctx = new CliContext(System.in, printer, System.err, ExitType.EXCEPTION);
+    List<List<String>> lists =
+        Collections.singletonList(
+            Arrays.asList("distribution plan", "{", "  \"name\": \"OutputNode-1\"", "}"));
+
+    setStaticField("lineCount", 0);
+    setStaticField("isReachEnd", true);
+    outputRawJson(ctx, lists, Collections.singletonList(128));
+
+    List<String> lines =
+        Arrays.asList(new String(out.toByteArray(), StandardCharsets.UTF_8).split("\\R"));
+    assertEquals("+-----------------+", lines.get(0));
+    assertEquals("|distribution plan|", lines.get(1));
+    assertEquals("+-----------------+", lines.get(2));
+    assertEquals("{", lines.get(3));
+    assertEquals("  \"name\": \"OutputNode-1\"", lines.get(4));
+    assertEquals("}", lines.get(5));
+    assertEquals("+-----------------+", lines.get(6));
+    assertEquals("Total line number = 3", lines.get(7));
+    assertFalse(lines.get(3).startsWith("|"));
+  }
+
+  private static List<List<String>> column(String header, String value) {
+    return Collections.singletonList(Arrays.asList(header, value));
+  }
+
+  private static boolean isJsonExplainResult(List<List<String>> lists) throws Exception {
+    Method method = AbstractCli.class.getDeclaredMethod("isJsonExplainResult", List.class);
+    method.setAccessible(true);
+    return (boolean) method.invoke(null, lists);
+  }
+
+  private static void outputRawJson(
+      CliContext ctx, List<List<String>> lists, List<Integer> maxSizeList) throws Exception {
+    Method method =
+        AbstractCli.class.getDeclaredMethod(
+            "outputRawJson", CliContext.class, List.class, List.class);
+    method.setAccessible(true);
+    method.invoke(null, ctx, lists, maxSizeList);
+  }
+
+  private static void setStaticField(String name, Object value) throws Exception {
+    Field field = AbstractCli.class.getDeclaredField(name);
+    field.setAccessible(true);
+    field.set(null, value);
+  }
 }
diff --git a/iotdb-core/datanode/pom.xml b/iotdb-core/datanode/pom.xml
index 97a296c9a9e..ce879cb43cf 100644
--- a/iotdb-core/datanode/pom.xml
+++ b/iotdb-core/datanode/pom.xml
@@ -259,6 +259,10 @@
             <groupId>com.google.guava</groupId>
             <artifactId>guava</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.java-websocket</groupId>
             <artifactId>Java-WebSocket</artifactId>
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 1e5bd2bff77..6ef5804fac8 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
@@ -39,6 +39,7 @@ import org.apache.iotdb.db.queryengine.plan.analyze.QueryType;
 import org.apache.iotdb.db.queryengine.plan.analyze.TypeProvider;
 import org.apache.iotdb.db.queryengine.plan.analyze.lock.SchemaLockType;
 import org.apache.iotdb.db.queryengine.plan.planner.memory.NotThreadSafeMemoryReservationManager;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 import org.apache.iotdb.db.queryengine.statistics.QueryPlanStatistics;
 
 import com.google.common.collect.ImmutableList;
@@ -109,6 +110,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 = ExplainOutputFormat.TEXT;
   private boolean verbose = false;
 
   private QueryPlanStatistics queryPlanStatistics = null;
@@ -353,6 +355,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 c2601a2ebc2..7beedf6d67c 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
@@ -36,7 +36,9 @@ import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
 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.sql.ast.ExplainOutputFormat;
 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;
@@ -80,23 +82,36 @@ 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 FragmentInstanceStatisticsDrawer fragmentInstanceStatisticsDrawer;
+  private final FragmentInstanceStatisticsJsonDrawer fragmentInstanceStatisticsJsonDrawer;
 
   private final ScheduledFuture<?> logRecordTask;
   private final IClientManager<TEndPoint, SyncDataNodeInternalServiceClient> clientManager;
   private final MPPQueryContext mppQueryContext;
 
+  @Deprecated
   public ExplainAnalyzeOperator(
       OperatorContext operatorContext,
       Operator child,
       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();
@@ -104,7 +119,16 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
     QueryExecution queryExecution = (QueryExecution) coordinator.getQueryExecution(queryId);
     this.instances = queryExecution.getDistributedPlan().getInstances();
     mppQueryContext = queryExecution.getContext();
-    fragmentInstanceStatisticsDrawer.renderPlanStatistics(mppQueryContext);
+
+    if (outputFormat == ExplainOutputFormat.JSON) {
+      this.fragmentInstanceStatisticsDrawer = null;
+      this.fragmentInstanceStatisticsJsonDrawer = new FragmentInstanceStatisticsJsonDrawer();
+      fragmentInstanceStatisticsJsonDrawer.renderPlanStatistics(mppQueryContext);
+    } else {
+      this.fragmentInstanceStatisticsDrawer = new FragmentInstanceStatisticsDrawer();
+      this.fragmentInstanceStatisticsJsonDrawer = null;
+      fragmentInstanceStatisticsDrawer.renderPlanStatistics(mppQueryContext);
+    }
 
     // The time interval guarantees the result of EXPLAIN ANALYZE will be printed at least three
     // times.
@@ -131,7 +155,11 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
       return null;
     }
 
-    fragmentInstanceStatisticsDrawer.renderDispatchCost(mppQueryContext);
+    if (outputFormat == ExplainOutputFormat.JSON) {
+      fragmentInstanceStatisticsJsonDrawer.renderDispatchCost(mppQueryContext);
+    } else {
+      fragmentInstanceStatisticsDrawer.renderDispatchCost(mppQueryContext);
+    }
 
     // fetch statics from all fragment instances
     TsBlock result = buildResult();
@@ -161,6 +189,14 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
     return analyzeResult;
   }
 
+  private String buildFragmentInstanceStatisticsJson(
+      List<FragmentInstance> instances, boolean verbose) throws FragmentInstanceFetchException {
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStatistics =
+        QueryStatisticsFetcher.fetchAllStatistics(instances, clientManager);
+    return fragmentInstanceStatisticsJsonDrawer.renderFragmentInstancesAsJson(
+        instances, allStatistics, verbose);
+  }
+
   // We will log the intermediate result of analyze if timeout
   // It can be used to analyze deadlock problem.
   private void logIntermediateResultIfTimeout() {
@@ -169,12 +205,15 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
             String.format(
                 "%s-Explain-Analyze-Logger",
                 operatorContext.getInstanceContext().getId().getQueryId()))) {
-      List<String> analyzeResult = buildFragmentInstanceStatistics(instances, verbose);
-
       StringBuilder logContent = new StringBuilder();
       logContent.append("\n").append(LOG_TITLE).append("\n");
-      for (String line : analyzeResult) {
-        logContent.append(line).append("\n");
+      if (outputFormat == ExplainOutputFormat.JSON) {
+        logContent.append(buildFragmentInstanceStatisticsJson(instances, verbose)).append("\n");
+      } else {
+        List<String> analyzeResult = buildFragmentInstanceStatistics(instances, verbose);
+        for (String line : analyzeResult) {
+          logContent.append(line).append("\n");
+        }
       }
       String res = logContent.toString();
       logger.info(res);
@@ -185,6 +224,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);
@@ -202,6 +244,18 @@ public class ExplainAnalyzeOperator implements ProcessOperator {
     return builder.build();
   }
 
+  private TsBlock buildJsonResult() throws FragmentInstanceFetchException {
+    String jsonResult = buildFragmentInstanceStatisticsJson(instances, 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/Coordinator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/Coordinator.java
index bd1512aa428..d520bdf6a2c 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/Coordinator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/Coordinator.java
@@ -98,6 +98,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Execute;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExecuteImmediate;
 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.ExtendRegion;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Flush;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.KillQuery;
@@ -441,6 +442,7 @@ public class Coordinator {
       Metadata metadata,
       Map<NodeRef<Table>, Query> cteQueries,
       ExplainType explainType,
+      ExplainOutputFormat explainOutputFormat,
       long timeOut,
       boolean userQuery,
       boolean debug,
@@ -455,6 +457,7 @@ public class Coordinator {
           queryContext.setInnerTriggeredQuery(true);
           queryContext.setCteQueries(cteQueries);
           queryContext.setExplainType(explainType);
+          queryContext.setExplainOutputFormat(explainOutputFormat);
           queryContext.setVerbose(isVerbose);
           return createQueryExecutionForTableModel(
               statement,
@@ -720,9 +723,12 @@ public class Coordinator {
       parameters = new ArrayList<>(executeStatement.getParameters());
 
       if (statement instanceof Explain) {
-        statementToUse = new Explain(resolvedSql);
+        statementToUse = new Explain(resolvedSql, ((Explain) statement).getOutputFormat());
       } else if (statement instanceof ExplainAnalyze) {
-        statementToUse = new ExplainAnalyze(resolvedSql, ((ExplainAnalyze) statement).isVerbose());
+        ExplainAnalyze explainAnalyze = (ExplainAnalyze) statement;
+        statementToUse =
+            new ExplainAnalyze(
+                resolvedSql, explainAnalyze.isVerbose(), explainAnalyze.getOutputFormat());
       } else {
         statementToUse = resolvedSql;
       }
@@ -742,9 +748,12 @@ public class Coordinator {
       }
 
       if (statement instanceof Explain) {
-        statementToUse = new Explain(resolvedSql);
+        statementToUse = new Explain(resolvedSql, ((Explain) statement).getOutputFormat());
       } else if (statement instanceof ExplainAnalyze) {
-        statementToUse = new ExplainAnalyze(resolvedSql, ((ExplainAnalyze) statement).isVerbose());
+        ExplainAnalyze explainAnalyze = (ExplainAnalyze) statement;
+        statementToUse =
+            new ExplainAnalyze(
+                resolvedSql, explainAnalyze.isVerbose(), explainAnalyze.getOutputFormat());
       } else {
         statementToUse = resolvedSql;
       }
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 39b771c45c3..38fc437d1b7 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
@@ -29,6 +29,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.relational.planner.SymbolAllocator;
 import org.apache.iotdb.db.queryengine.plan.relational.planner.TableLogicalPlanner;
@@ -37,8 +38,14 @@ 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.ShowDevice;
 
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
 import org.apache.tsfile.enums.TSDataType;
 import org.apache.tsfile.read.common.block.TsBlock;
 import org.apache.tsfile.utils.Pair;
@@ -58,6 +65,8 @@ import static org.apache.iotdb.db.queryengine.plan.execution.memory.StatementMem
 public class TableModelStatementMemorySourceVisitor
     implements AstVisitor<StatementMemorySource, TableModelStatementMemorySourceContext> {
 
+  private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
   @Override
   public StatementMemorySource visitNode(
       final Node node, final TableModelStatementMemorySourceContext context) {
@@ -103,15 +112,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 +169,26 @@ 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;
+    }
+
+    JsonObject wrapper = new JsonObject();
+    JsonArray cteArray = new JsonArray();
+    for (Map.Entry<NodeRef<Table>, Pair<Integer, List<String>>> entry :
+        cteExplainResults.entrySet()) {
+      JsonObject cte = new JsonObject();
+      cte.addProperty("name", entry.getKey().getNode().getName().toString());
+      cte.add("plan", JsonParser.parseString(entry.getValue().getRight().get(0)));
+      cteArray.add(cte);
+    }
+    wrapper.add("cteQueries", cteArray);
+    wrapper.add("mainQuery", JsonParser.parseString(mainExplainResult.get(0)));
+
+    return Collections.singletonList(GSON.toJson(wrapper));
+  }
 }
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java
index b5de9a5561e..9f9d69e23e3 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java
@@ -1867,7 +1867,12 @@ public class DataNodeTableOperatorGenerator
         addOperatorContext(
             context, 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..323c816d8bd
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/plan/node/PlanGraphJsonPrinter.java
@@ -0,0 +1,225 @@
+/*
+ * 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.commons.queryengine.plan.planner.plan.node.PlanNode;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.TableScanNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.AggregationNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.FilterNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.JoinNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.MergeSortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OffsetNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.ProjectNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.SortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.UnionNode;
+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.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);
+    // JsonObject.isEmpty() is not available in all Gson versions
+    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.add("OutputColumns", toJsonArray(n.getOutputColumnNames()));
+      properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+    } else if (node instanceof ExplainAnalyzeNode) {
+      ExplainAnalyzeNode n = (ExplainAnalyzeNode) node;
+      properties.add("ChildPermittedOutputs", toJsonArray(n.getChildPermittedOutputs()));
+    } else if (node instanceof TableScanNode) {
+      buildTableScanProperties(properties, (TableScanNode) node);
+    } else if (node instanceof ExchangeNode) {
+      // No extra properties needed
+    } else if (node instanceof AggregationNode) {
+      buildAggregationProperties(properties, (AggregationNode) node);
+    } else if (node instanceof FilterNode) {
+      FilterNode n = (FilterNode) node;
+      properties.addProperty("Predicate", String.valueOf(n.getPredicate()));
+    } else if (node instanceof ProjectNode) {
+      ProjectNode n = (ProjectNode) node;
+      properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+      properties.add("Expressions", toJsonArray(n.getAssignments().getMap().values()));
+    } else if (node instanceof LimitNode) {
+      LimitNode n = (LimitNode) node;
+      properties.addProperty("Count", n.getCount());
+    } else if (node instanceof OffsetNode) {
+      OffsetNode n = (OffsetNode) node;
+      properties.addProperty("Count", n.getCount());
+    } else if (node instanceof SortNode) {
+      SortNode n = (SortNode) node;
+      properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+    } else if (node instanceof MergeSortNode) {
+      MergeSortNode n = (MergeSortNode) node;
+      properties.addProperty("OrderBy", String.valueOf(n.getOrderingScheme()));
+    } else if (node instanceof JoinNode) {
+      JoinNode n = (JoinNode) node;
+      properties.addProperty("JoinType", String.valueOf(n.getJoinType()));
+      properties.add("Criteria", toJsonArray(n.getCriteria()));
+      properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+    } else if (node instanceof UnionNode) {
+      UnionNode n = (UnionNode) node;
+      properties.add("OutputSymbols", toJsonArray(n.getOutputSymbols()));
+    }
+
+    return properties;
+  }
+
+  private static <T> JsonArray toJsonArray(java.util.Collection<T> items) {
+    JsonArray array = new JsonArray();
+    for (T item : items) {
+      array.add(String.valueOf(item));
+    }
+    return array;
+  }
+
+  private static <T> JsonArray toJsonArray(List<T> items) {
+    JsonArray array = new JsonArray();
+    for (T item : items) {
+      array.add(String.valueOf(item));
+    }
+    return array;
+  }
+
+  private static void buildTableScanProperties(JsonObject properties, TableScanNode node) {
+    properties.addProperty("QualifiedTableName", node.getQualifiedObjectName().toString());
+    properties.add("OutputSymbols", toJsonArray(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, AggregationNode node) {
+    properties.add("OutputSymbols", toJsonArray(node.getOutputSymbols()));
+
+    JsonArray aggregators = new JsonArray();
+    int i = 0;
+    for (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.add("GroupingKeys", toJsonArray(node.getGroupingKeys()));
+    if (node.isStreamable()) {
+      properties.addProperty("Streamable", true);
+      properties.add("PreGroupedSymbols", toJsonArray(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 132a2470299..a2d606dbd06 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
@@ -927,6 +927,7 @@ public class StatementAnalyzer {
     @Override
     public Scope visitExplain(Explain node, Optional<Scope> context) {
       queryContext.setExplainType(ExplainType.EXPLAIN);
+      queryContext.setExplainOutputFormat(node.getOutputFormat());
       analysis.setFinishQueryAfterAnalyze();
       return visitQuery((Query) node.getStatement(), context);
     }
@@ -943,6 +944,7 @@ public class StatementAnalyzer {
     public 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/CteMaterializer.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializer.java
index 0d949340513..d1dec3e8665 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializer.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializer.java
@@ -142,6 +142,7 @@ public class CteMaterializer {
               LocalExecutionPlanner.getInstance().metadata,
               context.getCteQueries(),
               context.getExplainType(),
+              context.getExplainOutputFormat(),
               context.getTimeOut(),
               false,
               context.isDebug(),
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 77507fc8bab..6ddf235e392 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
@@ -627,7 +627,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/distribute/TableDistributedPlanner.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanner.java
index 20d53390439..2e89fa578fc 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanner.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/distribute/TableDistributedPlanner.java
@@ -33,6 +33,7 @@ import org.apache.iotdb.db.queryengine.plan.planner.plan.DistributedQueryPlan;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.LogicalQueryPlan;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.SubPlan;
+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.WritePlanNode;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.sink.IdentitySinkNode;
@@ -45,6 +46,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExchangeNode
 import org.apache.iotdb.db.queryengine.plan.relational.planner.optimizations.DataNodeLocationSupplierFactory;
 import org.apache.iotdb.db.queryengine.plan.relational.planner.optimizations.DistributedOptimizeFactory;
 import org.apache.iotdb.db.queryengine.plan.relational.planner.optimizations.PlanOptimizer;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 
 import java.util.Collections;
 import java.util.HashMap;
@@ -104,11 +106,15 @@ public class TableDistributedPlanner {
     PlanNode outputNodeWithExchange = generateDistributedPlanWithOptimize(planContext);
     List<String> planText = null;
     if (mppQueryContext.isExplain() && mppQueryContext.isInnerTriggeredQuery()) {
-      planText =
-          outputNodeWithExchange.accept(
-              new PlanGraphPrinter(),
-              new PlanGraphPrinter.GraphContext(
-                  mppQueryContext.getTypeProvider().getTemplatedInfo()));
+      if (mppQueryContext.getExplainOutputFormat() == ExplainOutputFormat.JSON) {
+        planText = PlanGraphJsonPrinter.getJsonLines(outputNodeWithExchange);
+      } else {
+        planText =
+            outputNodeWithExchange.accept(
+                new PlanGraphPrinter(),
+                new PlanGraphPrinter.GraphContext(
+                    mppQueryContext.getTypeProvider().getTemplatedInfo()));
+      }
     }
 
     if (analysis.isQuery()) {
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java
index 3e0ef250eb4..acd900179a9 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/ir/PredicateWithUncorrelatedScalarSubqueryReconstructor.java
@@ -47,6 +47,7 @@ import org.apache.iotdb.db.queryengine.plan.Coordinator;
 import org.apache.iotdb.db.queryengine.plan.execution.ExecutionResult;
 import org.apache.iotdb.db.queryengine.plan.planner.LocalExecutionPlanner;
 import org.apache.iotdb.db.queryengine.plan.relational.analyzer.Analysis;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.parser.SqlParser;
 import org.apache.iotdb.rpc.TSStatusCode;
 
@@ -153,6 +154,7 @@ public class PredicateWithUncorrelatedScalarSubqueryReconstructor {
               LocalExecutionPlanner.getInstance().metadata,
               context.getCteQueries(),
               ExplainType.NONE,
+              ExplainOutputFormat.GRAPHVIZ,
               context.getTimeOut(),
               false,
               q.isDebug(),
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 389fbc5bfac..820bdc96285 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
@@ -26,6 +26,7 @@ import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.process.Singl
 import org.apache.iotdb.commons.queryengine.plan.relational.planner.Symbol;
 import org.apache.iotdb.db.i18n.DataNodeQueryMessages;
 import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanVisitor;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
 
 import java.io.DataOutputStream;
 import java.io.IOException;
@@ -40,6 +41,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,
@@ -49,18 +51,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
@@ -91,7 +121,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
@@ -120,6 +151,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/planner/optimizations/UnaliasSymbolReferences.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/optimizations/UnaliasSymbolReferences.java
index eeda5d77094..58fd2dbd613 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/optimizations/UnaliasSymbolReferences.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/planner/optimizations/UnaliasSymbolReferences.java
@@ -418,7 +418,8 @@ public class UnaliasSymbolReferences implements PlanOptimizer {
               node.getQueryId(),
               node.getTimeout(),
               node.getOutputSymbols().get(0),
-              newChildPermittedOutputs),
+              newChildPermittedOutputs,
+              node.getOutputFormat()),
           mapping);
     }
 
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 0e98aabfd12..f9417e75d42 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
@@ -39,21 +39,40 @@ 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(Statement statement, ExplainOutputFormat outputFormat) {
+    super(null);
+    this.statement = requireNonNull(statement, "statement is null");
+    this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
   }
 
   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(IAstVisitor<R, C> visitor, C context) {
     return ((AstVisitor<R, C>) visitor).visitExplain(this, context);
@@ -66,7 +85,7 @@ public class Explain extends Statement {
 
   @Override
   public int hashCode() {
-    return Objects.hash(statement);
+    return Objects.hash(statement, outputFormat);
   }
 
   @Override
@@ -78,12 +97,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 1c158a1b279..9e7c2f24de1 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
@@ -41,17 +41,38 @@ 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(Statement statement, boolean verbose, ExplainOutputFormat outputFormat) {
+    super(null);
+    this.statement = requireNonNull(statement, "statement is null");
+    this.verbose = verbose;
+    this.outputFormat = requireNonNull(outputFormat, "outputFormat is null");
   }
 
   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() {
@@ -62,6 +83,10 @@ public class ExplainAnalyze extends Statement {
     return verbose;
   }
 
+  public ExplainOutputFormat getOutputFormat() {
+    return outputFormat;
+  }
+
   @Override
   public <R, C> R accept(IAstVisitor<R, C> visitor, C context) {
     return ((AstVisitor<R, C>) visitor).visitExplainAnalyze(this, context);
@@ -74,7 +99,7 @@ public class ExplainAnalyze extends Statement {
 
   @Override
   public int hashCode() {
-    return Objects.hash(statement, verbose);
+    return Objects.hash(statement, verbose, outputFormat);
   }
 
   @Override
@@ -86,12 +111,18 @@ public class ExplainAnalyze extends Statement {
       return false;
     }
     ExplainAnalyze o = (ExplainAnalyze) obj;
-    return Objects.equals(statement, o.statement);
+    return Objects.equals(statement, o.statement)
+        && verbose == o.verbose
+        && outputFormat == o.outputFormat;
   }
 
   @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..d7d1fc08bb3
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ast/ExplainOutputFormat.java
@@ -0,0 +1,35 @@
+/*
+ * 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;
+
+/**
+ * Output format for EXPLAIN and EXPLAIN ANALYZE statements.
+ *
+ * <ul>
+ *   <li>{@link #GRAPHVIZ} - Box-drawing plan visualization. Valid for EXPLAIN only (default).
+ *   <li>{@link #TEXT} - Text-based output. Valid for EXPLAIN ANALYZE only (default).
+ *   <li>{@link #JSON} - Structured JSON output. Valid for both EXPLAIN and EXPLAIN ANALYZE.
+ * </ul>
+ */
+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 a19168b50f4..b32efbbccf4 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
@@ -194,6 +194,7 @@ import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Execute;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExecuteImmediate;
 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.ExtendRegion;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Flush;
 import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.Insert;
@@ -1821,7 +1822,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
@@ -1834,7 +1850,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..74571ad152f
--- /dev/null
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawer.java
@@ -0,0 +1,372 @@
+/*
+ * 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.commons.queryengine.plan.planner.plan.node.PlanNode;
+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.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);
+        }
+      }
+      // JsonArray.isEmpty() is not available in all Gson versions
+      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/execution/operator/ExplainAnalyzeOperatorTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperatorTest.java
new file mode 100644
index 00000000000..bb5b007ab44
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/execution/operator/ExplainAnalyzeOperatorTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.execution.operator;
+
+import org.apache.iotdb.calc.execution.operator.Operator;
+import org.apache.iotdb.common.rpc.thrift.TEndPoint;
+import org.apache.iotdb.commons.queryengine.common.SessionInfo;
+import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
+import org.apache.iotdb.db.queryengine.common.QueryId;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
+import org.apache.iotdb.db.queryengine.statistics.FragmentInstanceStatisticsJsonDrawer;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.tsfile.common.conf.TSFileConfig;
+import org.apache.tsfile.read.common.block.TsBlock;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.time.ZoneId;
+import java.util.Collections;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+public class ExplainAnalyzeOperatorTest {
+
+  @Test
+  public void testNextBuildsSingleJsonAnalyzeResult() throws Exception {
+    ExplainAnalyzeOperator explainAnalyzeOperator =
+        Mockito.mock(ExplainAnalyzeOperator.class, Mockito.CALLS_REAL_METHODS);
+    Operator child = Mockito.mock(Operator.class);
+    when(child.hasNextWithTimer()).thenReturn(false);
+
+    MPPQueryContext context = createTestContext();
+    context.setAnalyzeCost(1000000L);
+    context.recordDispatchCost(2000000L);
+    FragmentInstanceStatisticsJsonDrawer jsonDrawer = new FragmentInstanceStatisticsJsonDrawer();
+    jsonDrawer.renderPlanStatistics(context);
+
+    setField(explainAnalyzeOperator, "child", child);
+    setField(explainAnalyzeOperator, "verbose", true);
+    setField(explainAnalyzeOperator, "instances", Collections.emptyList());
+    setField(explainAnalyzeOperator, "outputFormat", ExplainOutputFormat.JSON);
+    setField(explainAnalyzeOperator, "fragmentInstanceStatisticsJsonDrawer", jsonDrawer);
+    setField(explainAnalyzeOperator, "clientManager", null);
+    setField(explainAnalyzeOperator, "mppQueryContext", context);
+
+    TsBlock result = explainAnalyzeOperator.next();
+
+    assertEquals(1, result.getPositionCount());
+    String json = result.getColumn(0).getBinary(0).getStringValue(TSFileConfig.STRING_CHARSET);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+    assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+    assertTrue(root.getAsJsonObject("planStatistics").has("analyzeCostMs"));
+    assertTrue(root.getAsJsonObject("planStatistics").has("dispatchCostMs"));
+  }
+
+  private static MPPQueryContext createTestContext() {
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    MPPQueryContext context =
+        new MPPQueryContext(
+            "EXPLAIN ANALYZE (FORMAT JSON) SELECT * FROM table1",
+            new QueryId("test_query_id"),
+            sessionInfo,
+            new TEndPoint("127.0.0.1", 6667),
+            new TEndPoint("127.0.0.1", 10730));
+    context.setAnalyzeCost(0);
+    return context;
+  }
+
+  private static void setField(Object target, String name, Object value) throws Exception {
+    Field field = ExplainAnalyzeOperator.class.getDeclaredField(name);
+    field.setAccessible(true);
+    field.set(target, value);
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitorTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitorTest.java
new file mode 100644
index 00000000000..134d94aca36
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/execution/memory/TableModelStatementMemorySourceVisitorTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.execution.memory;
+
+import org.apache.iotdb.commons.queryengine.plan.relational.analyzer.NodeRef;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.QualifiedName;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Table;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.tsfile.utils.Pair;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+public class TableModelStatementMemorySourceVisitorTest {
+
+  @Test
+  public void testMergeExplainResultsJsonReturnsMainResultWithoutCte() throws Exception {
+    List<String> mainExplainResult =
+        Collections.singletonList("{\"name\":\"OutputNode-main\",\"id\":\"main\"}");
+
+    List<String> result = mergeExplainResultsJson(Collections.emptyMap(), mainExplainResult);
+
+    assertSame(mainExplainResult, result);
+  }
+
+  @Test
+  public void testMergeExplainResultsJsonWrapsCteAndMainPlans() throws Exception {
+    Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults = new LinkedHashMap<>();
+    cteExplainResults.put(
+        NodeRef.of(new Table(QualifiedName.of("cte1"))),
+        new Pair<>(0, Collections.singletonList("{\"name\":\"OutputNode-cte\",\"id\":\"cte\"}")));
+    List<String> mainExplainResult =
+        Collections.singletonList("{\"name\":\"OutputNode-main\",\"id\":\"main\"}");
+
+    List<String> result = mergeExplainResultsJson(cteExplainResults, mainExplainResult);
+
+    assertEquals(1, result.size());
+    JsonObject root = JsonParser.parseString(result.get(0)).getAsJsonObject();
+    JsonArray cteQueries = root.getAsJsonArray("cteQueries");
+    assertEquals(1, cteQueries.size());
+
+    JsonObject cte = cteQueries.get(0).getAsJsonObject();
+    assertEquals("cte1", cte.get("name").getAsString());
+    assertEquals("OutputNode-cte", cte.getAsJsonObject("plan").get("name").getAsString());
+    assertEquals("OutputNode-main", root.getAsJsonObject("mainQuery").get("name").getAsString());
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<String> mergeExplainResultsJson(
+      Map<NodeRef<Table>, Pair<Integer, List<String>>> cteExplainResults,
+      List<String> mainExplainResult)
+      throws Exception {
+    Method method =
+        TableModelStatementMemorySourceVisitor.class.getDeclaredMethod(
+            "mergeExplainResultsJson", Map.class, List.class);
+    method.setAccessible(true);
+    return (List<String>)
+        method.invoke(
+            new TableModelStatementMemorySourceVisitor(), cteExplainResults, mainExplainResult);
+  }
+}
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..8c901d21243
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/planner/node/PlanGraphJsonPrinterTest.java
@@ -0,0 +1,573 @@
+/*
+ * 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.common.rpc.thrift.TConsensusGroupId;
+import org.apache.iotdb.common.rpc.thrift.TConsensusGroupType;
+import org.apache.iotdb.common.rpc.thrift.TRegionReplicaSet;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.commons.queryengine.plan.relational.function.BoundSignature;
+import org.apache.iotdb.commons.queryengine.plan.relational.function.FunctionId;
+import org.apache.iotdb.commons.queryengine.plan.relational.function.FunctionKind;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.ColumnSchema;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.FunctionNullability;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.QualifiedObjectName;
+import org.apache.iotdb.commons.queryengine.plan.relational.metadata.ResolvedFunction;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.Assignments;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.OrderingScheme;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.SortOrder;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.Symbol;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.AggregationNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.FilterNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.JoinNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.MergeSortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OffsetNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.OutputNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.ProjectNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.SortNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.UnionNode;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.ComparisonExpression;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.GenericLiteral;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.SymbolReference;
+import org.apache.iotdb.commons.schema.table.column.TsTableColumnCategory;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.node.PlanGraphJsonPrinter;
+import org.apache.iotdb.db.queryengine.plan.relational.metadata.AlignedDeviceEntry;
+import org.apache.iotdb.db.queryengine.plan.relational.metadata.DeviceEntry;
+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.TreeDeviceViewScanNode;
+import org.apache.iotdb.db.queryengine.plan.relational.sql.ast.ExplainOutputFormat;
+import org.apache.iotdb.db.queryengine.plan.statement.component.Ordering;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.file.metadata.StringArrayDeviceID;
+import org.apache.tsfile.read.common.type.TypeFactory;
+import org.apache.tsfile.utils.Binary;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+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());
+  }
+
+  @Test
+  public void testOffsetNode() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    OffsetNode offsetNode = new OffsetNode(new PlanNodeId("10"), child, 5);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(offsetNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("OffsetNode-10", root.get("name").getAsString());
+    assertEquals("10", root.get("id").getAsString());
+    assertTrue(root.has("properties"));
+    assertEquals("5", root.getAsJsonObject("properties").get("Count").getAsString());
+    assertTrue(root.has("children"));
+  }
+
+  @Test
+  public void testProjectNode() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    Symbol s1 = new Symbol("s1");
+    Symbol s2 = new Symbol("s2");
+    Assignments assignments =
+        Assignments.of(s1, new SymbolReference("s1"), s2, new SymbolReference("s2"));
+
+    ProjectNode projectNode = new ProjectNode(new PlanNodeId("20"), child, assignments);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(projectNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("ProjectNode-20", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    JsonObject props = root.getAsJsonObject("properties");
+    assertTrue(props.has("OutputSymbols"));
+    assertTrue(props.has("Expressions"));
+  }
+
+  @Test
+  public void testFilterNodeWithPredicate() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    FilterNode filterNode =
+        new FilterNode(
+            new PlanNodeId("30"),
+            child,
+            new ComparisonExpression(
+                ComparisonExpression.Operator.GREATER_THAN,
+                new SymbolReference("temperature"),
+                new GenericLiteral("FLOAT", "25.0")));
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(filterNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("FilterNode-30", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    assertTrue(root.getAsJsonObject("properties").has("Predicate"));
+  }
+
+  @Test
+  public void testSortNode() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    Symbol s1 = new Symbol("s1");
+    OrderingScheme orderingScheme =
+        new OrderingScheme(ImmutableList.of(s1), ImmutableMap.of(s1, SortOrder.ASC_NULLS_FIRST));
+
+    SortNode sortNode = new SortNode(new PlanNodeId("40"), child, orderingScheme, false, false);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(sortNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("SortNode-40", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    assertTrue(root.getAsJsonObject("properties").has("OrderBy"));
+  }
+
+  @Test
+  public void testMergeSortNode() {
+    Symbol s1 = new Symbol("s1");
+    OrderingScheme orderingScheme =
+        new OrderingScheme(ImmutableList.of(s1), ImmutableMap.of(s1, SortOrder.DESC_NULLS_LAST));
+
+    LimitNode child1 = new LimitNode(new PlanNodeId("c1"), null, 0, Optional.empty());
+    LimitNode child2 = new LimitNode(new PlanNodeId("c2"), null, 0, Optional.empty());
+
+    MergeSortNode mergeSortNode =
+        new MergeSortNode(
+            new PlanNodeId("50"),
+            Arrays.asList(child1, child2),
+            orderingScheme,
+            ImmutableList.of(s1));
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(mergeSortNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("MergeSortNode-50", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    assertTrue(root.getAsJsonObject("properties").has("OrderBy"));
+    assertTrue(root.has("children"));
+    assertEquals(2, root.getAsJsonArray("children").size());
+  }
+
+  @Test
+  public void testExchangeNode() {
+    ExchangeNode exchangeNode = new ExchangeNode(new PlanNodeId("60"));
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(exchangeNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("ExchangeNode-60", root.get("name").getAsString());
+    assertEquals("60", root.get("id").getAsString());
+    // ExchangeNode has no extra properties
+    assertFalse(root.has("properties"));
+  }
+
+  @Test
+  public void testExplainAnalyzeNode() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    Symbol outputSymbol = new Symbol("explain_result");
+    List<Symbol> childPermittedOutputs = Arrays.asList(new Symbol("s1"), new Symbol("s2"));
+
+    ExplainAnalyzeNode explainNode =
+        new ExplainAnalyzeNode(
+            new PlanNodeId("70"),
+            child,
+            true,
+            12345L,
+            60000L,
+            outputSymbol,
+            childPermittedOutputs,
+            ExplainOutputFormat.JSON);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(explainNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("ExplainAnalyzeNode-70", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    JsonArray permittedOutputs =
+        root.getAsJsonObject("properties").getAsJsonArray("ChildPermittedOutputs");
+    assertNotNull(permittedOutputs);
+    assertEquals(2, permittedOutputs.size());
+  }
+
+  @Test
+  public void testJoinNode() {
+    ExchangeNode leftChild = new ExchangeNode(new PlanNodeId("l"));
+    leftChild.setOutputSymbols(ImmutableList.of(new Symbol("left_s1")));
+    ExchangeNode rightChild = new ExchangeNode(new PlanNodeId("r"));
+    rightChild.setOutputSymbols(ImmutableList.of(new Symbol("right_s1")));
+    Symbol leftSymbol = new Symbol("left_s1");
+    Symbol rightSymbol = new Symbol("right_s1");
+
+    JoinNode joinNode =
+        new JoinNode(
+            new PlanNodeId("80"),
+            JoinNode.JoinType.INNER,
+            leftChild,
+            rightChild,
+            ImmutableList.of(new JoinNode.EquiJoinClause(leftSymbol, rightSymbol)),
+            Optional.empty(),
+            ImmutableList.of(leftSymbol),
+            ImmutableList.of(rightSymbol),
+            Optional.empty(),
+            Optional.empty());
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(joinNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("JoinNode-80", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    JsonObject props = root.getAsJsonObject("properties");
+    assertEquals("INNER", props.get("JoinType").getAsString());
+    assertTrue(props.has("Criteria"));
+    assertTrue(props.has("OutputSymbols"));
+    assertTrue(root.has("children"));
+    assertEquals(2, root.getAsJsonArray("children").size());
+  }
+
+  @Test
+  public void testUnionNode() {
+    ExchangeNode child1 = new ExchangeNode(new PlanNodeId("c1"));
+    child1.setOutputSymbols(ImmutableList.of(new Symbol("input_s1_a")));
+    ExchangeNode child2 = new ExchangeNode(new PlanNodeId("c2"));
+    child2.setOutputSymbols(ImmutableList.of(new Symbol("input_s1_b")));
+    Symbol out = new Symbol("output_s1");
+
+    Symbol in1 = new Symbol("input_s1_a");
+    Symbol in2 = new Symbol("input_s1_b");
+
+    UnionNode unionNode =
+        new UnionNode(
+            new PlanNodeId("90"),
+            Arrays.asList(child1, child2),
+            ImmutableListMultimap.of(out, in1, out, in2),
+            ImmutableList.of(out));
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(unionNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("UnionNode-90", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+    assertTrue(root.getAsJsonObject("properties").has("OutputSymbols"));
+    assertTrue(root.has("children"));
+    assertEquals(2, root.getAsJsonArray("children").size());
+  }
+
+  @Test
+  public void testNodeWithNoChildren() {
+    ExchangeNode exchangeNode = new ExchangeNode(new PlanNodeId("100"));
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(exchangeNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    // ExchangeNode has no children added
+    assertFalse(root.has("children"));
+  }
+
+  @Test
+  public void testOutputNodeProperties() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    List<Symbol> outputSymbols =
+        Arrays.asList(new Symbol("time"), new Symbol("device"), new Symbol("temperature"));
+    OutputNode outputNode =
+        new OutputNode(
+            new PlanNodeId("110"),
+            child,
+            Arrays.asList("time", "device", "temperature"),
+            outputSymbols);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(outputNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    JsonObject props = root.getAsJsonObject("properties");
+    assertNotNull(props);
+    JsonArray outputColumns = props.getAsJsonArray("OutputColumns");
+    assertEquals(3, outputColumns.size());
+    assertEquals("time", outputColumns.get(0).getAsString());
+    assertEquals("device", outputColumns.get(1).getAsString());
+    assertEquals("temperature", outputColumns.get(2).getAsString());
+
+    JsonArray outputSymbolsArray = props.getAsJsonArray("OutputSymbols");
+    assertEquals(3, outputSymbolsArray.size());
+  }
+
+  @Test
+  public void testDeepPlanHierarchy() {
+    // Build a 4-level deep plan: Output -> Sort -> Filter -> Limit
+    LimitNode leaf = new LimitNode(new PlanNodeId("leaf"), null, 100, Optional.empty());
+
+    FilterNode filterNode =
+        new FilterNode(
+            new PlanNodeId("filter"),
+            leaf,
+            new ComparisonExpression(
+                ComparisonExpression.Operator.LESS_THAN,
+                new SymbolReference("v"),
+                new GenericLiteral("INT32", "50")));
+
+    Symbol s1 = new Symbol("v");
+    OrderingScheme orderingScheme =
+        new OrderingScheme(ImmutableList.of(s1), ImmutableMap.of(s1, SortOrder.ASC_NULLS_LAST));
+    SortNode sortNode =
+        new SortNode(new PlanNodeId("sort"), filterNode, orderingScheme, false, false);
+
+    OutputNode outputNode =
+        new OutputNode(
+            new PlanNodeId("output"),
+            sortNode,
+            Collections.singletonList("v"),
+            ImmutableList.of(s1));
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(outputNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    // Verify 4 levels
+    assertEquals("OutputNode-output", root.get("name").getAsString());
+    JsonObject sort = root.getAsJsonArray("children").get(0).getAsJsonObject();
+    assertEquals("SortNode-sort", sort.get("name").getAsString());
+    JsonObject filter = sort.getAsJsonArray("children").get(0).getAsJsonObject();
+    assertEquals("FilterNode-filter", filter.get("name").getAsString());
+    JsonObject limit = filter.getAsJsonArray("children").get(0).getAsJsonObject();
+    assertEquals("LimitNode-leaf", limit.get("name").getAsString());
+    assertEquals("100", limit.getAsJsonObject("properties").get("Count").getAsString());
+  }
+
+  @Test
+  public void testExplainAnalyzeNodeDefaultFormat() {
+    LimitNode child = new LimitNode(new PlanNodeId("c"), null, 0, Optional.empty());
+    Symbol outputSymbol = new Symbol("result");
+    List<Symbol> childPermittedOutputs = ImmutableList.of(new Symbol("s1"));
+
+    // Use constructor with default TEXT format
+    ExplainAnalyzeNode explainNode =
+        new ExplainAnalyzeNode(
+            new PlanNodeId("ea"), child, false, 1L, 30000L, outputSymbol, childPermittedOutputs);
+
+    String json = PlanGraphJsonPrinter.toPrettyJson(explainNode);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals("ExplainAnalyzeNode-ea", root.get("name").getAsString());
+    assertTrue(root.has("properties"));
+  }
+
+  @Test
+  public void testTreeDeviceTableScanProperties() {
+    Symbol time = new Symbol("time");
+    Symbol value = new Symbol("value");
+    Symbol tag = new Symbol("tag1");
+    List<DeviceEntry> deviceEntries =
+        Collections.singletonList(
+            new AlignedDeviceEntry(new StringArrayDeviceID("table1", "d1"), new Binary[0]));
+    TreeDeviceViewScanNode scanNode =
+        new TreeDeviceViewScanNode(
+            new PlanNodeId("scan"),
+            new QualifiedObjectName("testdb", "table1"),
+            Arrays.asList(time, value),
+            ImmutableMap.of(
+                time,
+                new ColumnSchema(
+                    "time",
+                    TypeFactory.getType(TSDataType.TIMESTAMP),
+                    false,
+                    TsTableColumnCategory.TIME),
+                value,
+                new ColumnSchema(
+                    "value",
+                    TypeFactory.getType(TSDataType.INT64),
+                    false,
+                    TsTableColumnCategory.FIELD)),
+            deviceEntries,
+            ImmutableMap.of(tag, 0),
+            Ordering.DESC,
+            new ComparisonExpression(
+                ComparisonExpression.Operator.GREATER_THAN,
+                new SymbolReference("time"),
+                new GenericLiteral("INT64", "100")),
+            new ComparisonExpression(
+                ComparisonExpression.Operator.GREATER_THAN,
+                new SymbolReference("value"),
+                new GenericLiteral("INT64", "10")),
+            100,
+            5,
+            true,
+            false,
+            "root.testdb",
+            ImmutableMap.of("s1", "value"));
+    TRegionReplicaSet regionReplicaSet = new TRegionReplicaSet();
+    regionReplicaSet.setRegionId(new TConsensusGroupId(TConsensusGroupType.DataRegion, 7));
+    scanNode.setRegionReplicaSet(regionReplicaSet);
+
+    JsonObject root =
+        JsonParser.parseString(PlanGraphJsonPrinter.toPrettyJson(scanNode)).getAsJsonObject();
+    JsonObject props = root.getAsJsonObject("properties");
+
+    assertEquals("TreeDeviceViewScanNode-scan", root.get("name").getAsString());
+    assertEquals("testdb.table1", props.get("QualifiedTableName").getAsString());
+    assertEquals("1", props.get("DeviceNumber").getAsString());
+    assertEquals("DESC", props.get("ScanOrder").getAsString());
+    assertTrue(props.has("TimePredicate"));
+    assertTrue(props.has("PushDownPredicate"));
+    assertEquals("5", props.get("PushDownOffset").getAsString());
+    assertEquals("100", props.get("PushDownLimit").getAsString());
+    assertEquals("true", props.get("PushDownLimitToEachDevice").getAsString());
+    assertEquals("7", props.get("RegionId").getAsString());
+    assertEquals("root.testdb", props.get("TreeDB").getAsString());
+    assertTrue(props.get("MeasurementToColumnName").getAsString().contains("s1=value"));
+  }
+
+  @Test
+  public void testAggregationNodeProperties() {
+    Symbol group = new Symbol("device");
+    Symbol value = new Symbol("value");
+    Symbol mask = new Symbol("mask");
+    Symbol output = new Symbol("sum_value");
+    ResolvedFunction function =
+        new ResolvedFunction(
+            new BoundSignature(
+                "sum",
+                TypeFactory.getType(TSDataType.INT64),
+                Collections.singletonList(TypeFactory.getType(TSDataType.INT64))),
+            new FunctionId("sum"),
+            FunctionKind.AGGREGATE,
+            true,
+            FunctionNullability.getAggregationFunctionNullability(1));
+    AggregationNode.Aggregation aggregation =
+        new AggregationNode.Aggregation(
+            function,
+            Collections.singletonList(new SymbolReference("value")),
+            true,
+            Optional.empty(),
+            Optional.empty(),
+            Optional.of(mask));
+    AggregationNode aggregationNode =
+        new AggregationNode(
+            new PlanNodeId("agg"),
+            new ExchangeNode(new PlanNodeId("source")),
+            ImmutableMap.of(output, aggregation),
+            AggregationNode.singleGroupingSet(Collections.singletonList(group)),
+            Collections.singletonList(group),
+            AggregationNode.Step.SINGLE,
+            Optional.empty(),
+            Optional.empty());
+
+    JsonObject root =
+        JsonParser.parseString(PlanGraphJsonPrinter.toPrettyJson(aggregationNode))
+            .getAsJsonObject();
+    JsonObject props = root.getAsJsonObject("properties");
+    JsonObject firstAggregator = props.getAsJsonArray("Aggregators").get(0).getAsJsonObject();
+
+    assertEquals("AggregationNode-agg", root.get("name").getAsString());
+    assertEquals("sum(INT64):INT64", firstAggregator.get("function").getAsString());
+    assertEquals("mask", firstAggregator.get("mask").getAsString());
+    assertTrue(firstAggregator.get("distinct").getAsBoolean());
+    assertEquals("device", props.getAsJsonArray("GroupingKeys").get(0).getAsString());
+    assertTrue(props.get("Streamable").getAsBoolean());
+    assertEquals("device", props.getAsJsonArray("PreGroupedSymbols").get(0).getAsString());
+    assertEquals("SINGLE", props.get("Step").getAsString());
+  }
+}
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java
index 97273efb6ec..21225e38e59 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteMaterializerTest.java
@@ -115,6 +115,7 @@ public class CteMaterializerTest {
             Mockito.any(), // Metadata
             Mockito.anyMap(), // Map<NodeRef<Table>, CteDataStore>
             Mockito.any(), // ExplainType
+            Mockito.any(), // ExplainOutputFormat
             Mockito.anyLong(), // timeOut
             Mockito.anyBoolean(), // userQuery
             Mockito.anyBoolean(), // debug
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java
index 2a8c3e4777d..ef1b289a892 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/planner/CteSubqueryTest.java
@@ -138,6 +138,7 @@ public class CteSubqueryTest {
             Mockito.any(), // Metadata
             Mockito.anyMap(), // Map<NodeRef<Table>, CteDataStore>
             Mockito.any(), // ExplainType
+            Mockito.any(), // ExplainOutputFormat
             Mockito.anyLong(), // timeOut
             Mockito.anyBoolean(), // userQuery
             Mockito.anyBoolean(), // debug
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..9a90bc754ac
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/plan/relational/sql/ExplainFormatTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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.commons.queryengine.plan.relational.sql.ast.NodeLocation;
+import org.apache.iotdb.commons.queryengine.plan.relational.sql.ast.Statement;
+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.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.assertNotEquals;
+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
+  public void testExplainWithoutLocationKeepsOutputFormat() {
+    Statement stmt = parseSQL("SELECT * FROM table1");
+    Explain explain = new Explain(stmt, ExplainOutputFormat.JSON);
+
+    assertEquals(ExplainOutputFormat.JSON, explain.getOutputFormat());
+    assertEquals(stmt, explain.getStatement());
+  }
+
+  @Test
+  public void testExplainNodeOutputFormatParticipatesInNodeSemantics() {
+    Statement stmt = parseSQL("SELECT * FROM table1");
+    NodeLocation location = new NodeLocation(1, 1);
+    Explain defaultExplain = new Explain(location, stmt);
+    Explain jsonExplain = new Explain(location, stmt, ExplainOutputFormat.JSON);
+    Explain sameJsonExplain = new Explain(new NodeLocation(2, 1), stmt, ExplainOutputFormat.JSON);
+
+    assertEquals(ExplainOutputFormat.GRAPHVIZ, defaultExplain.getOutputFormat());
+    assertEquals(ExplainOutputFormat.JSON, jsonExplain.getOutputFormat());
+    assertEquals(stmt, jsonExplain.getChildren().get(0));
+    assertEquals(jsonExplain, sameJsonExplain);
+    assertEquals(jsonExplain.hashCode(), sameJsonExplain.hashCode());
+    assertNotEquals(defaultExplain, jsonExplain);
+    assertTrue(jsonExplain.toString().contains("outputFormat=JSON"));
+    assertTrue(jsonExplain.ramBytesUsed() > 0);
+  }
+
+  @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
+  public void testExplainAnalyzeWithoutLocationKeepsOutputFormat() {
+    Statement stmt = parseSQL("SELECT * FROM table1");
+    ExplainAnalyze explainAnalyze = new ExplainAnalyze(stmt, true, ExplainOutputFormat.JSON);
+
+    assertEquals(ExplainOutputFormat.JSON, explainAnalyze.getOutputFormat());
+    assertTrue(explainAnalyze.isVerbose());
+    assertEquals(stmt, explainAnalyze.getStatement());
+  }
+
+  @Test
+  public void testExplainAnalyzeNodeOutputFormatParticipatesInNodeSemantics() {
+    Statement stmt = parseSQL("SELECT * FROM table1");
+    NodeLocation location = new NodeLocation(1, 1);
+    ExplainAnalyze defaultAnalyze = new ExplainAnalyze(location, false, stmt);
+    ExplainAnalyze jsonAnalyze = new ExplainAnalyze(location, true, stmt, ExplainOutputFormat.JSON);
+    ExplainAnalyze sameJsonAnalyze =
+        new ExplainAnalyze(new NodeLocation(2, 1), true, stmt, ExplainOutputFormat.JSON);
+
+    assertEquals(ExplainOutputFormat.TEXT, defaultAnalyze.getOutputFormat());
+    assertEquals(ExplainOutputFormat.JSON, jsonAnalyze.getOutputFormat());
+    assertEquals(stmt, jsonAnalyze.getChildren().get(0));
+    assertEquals(jsonAnalyze, sameJsonAnalyze);
+    assertEquals(jsonAnalyze.hashCode(), sameJsonAnalyze.hashCode());
+    assertNotEquals(defaultAnalyze, jsonAnalyze);
+    assertNotEquals(new ExplainAnalyze(stmt, false, ExplainOutputFormat.JSON), jsonAnalyze);
+    assertTrue(jsonAnalyze.toString().contains("outputFormat=JSON"));
+    assertTrue(jsonAnalyze.ramBytesUsed() > 0);
+  }
+
+  @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/datanode/src/test/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawerTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawerTest.java
new file mode 100644
index 00000000000..b83501a277c
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/queryengine/statistics/FragmentInstanceStatisticsJsonDrawerTest.java
@@ -0,0 +1,594 @@
+/*
+ * 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.commons.queryengine.common.SessionInfo;
+import org.apache.iotdb.commons.queryengine.plan.planner.plan.node.PlanNodeId;
+import org.apache.iotdb.commons.queryengine.plan.relational.planner.node.LimitNode;
+import org.apache.iotdb.db.queryengine.common.FragmentInstanceId;
+import org.apache.iotdb.db.queryengine.common.MPPQueryContext;
+import org.apache.iotdb.db.queryengine.common.PlanFragmentId;
+import org.apache.iotdb.db.queryengine.common.QueryId;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.FragmentInstance;
+import org.apache.iotdb.db.queryengine.plan.planner.plan.PlanFragment;
+import org.apache.iotdb.db.queryengine.plan.relational.planner.node.ExchangeNode;
+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.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Test;
+
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class FragmentInstanceStatisticsJsonDrawerTest {
+
+  @Test
+  public void testRenderPlanStatistics() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    context.setAnalyzeCost(1000000L); // 1ms
+    context.setFetchPartitionCost(2000000L); // 2ms
+    context.setFetchSchemaCost(3000000L); // 3ms
+    context.setLogicalPlanCost(4000000L); // 4ms
+    context.setLogicalOptimizationCost(5000000L); // 5ms
+    context.setDistributionPlanCost(6000000L); // 6ms
+
+    drawer.renderPlanStatistics(context);
+
+    // Now render to JSON and verify planStatistics
+    drawer.renderDispatchCost(context);
+    String json =
+        drawer.renderFragmentInstancesAsJson(
+            Collections.emptyList(), Collections.emptyMap(), false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+    JsonObject planStats = root.getAsJsonObject("planStatistics");
+
+    assertNotNull(planStats);
+    assertEquals(1.0, planStats.get("analyzeCostMs").getAsDouble(), 0.01);
+    assertEquals(2.0, planStats.get("fetchPartitionCostMs").getAsDouble(), 0.01);
+    assertEquals(3.0, planStats.get("fetchSchemaCostMs").getAsDouble(), 0.01);
+    assertEquals(4.0, planStats.get("logicalPlanCostMs").getAsDouble(), 0.01);
+    assertEquals(5.0, planStats.get("logicalOptimizationCostMs").getAsDouble(), 0.01);
+    assertEquals(6.0, planStats.get("distributionPlanCostMs").getAsDouble(), 0.01);
+  }
+
+  @Test
+  public void testRenderDispatchCost() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    context.recordDispatchCost(7000000L); // 7ms
+
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(
+            Collections.emptyList(), Collections.emptyMap(), false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+    JsonObject planStats = root.getAsJsonObject("planStatistics");
+
+    assertNotNull(planStats);
+    assertEquals(7.0, planStats.get("dispatchCostMs").getAsDouble(), 0.01);
+  }
+
+  @Test
+  public void testRenderEmptyFragmentInstances() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(
+            Collections.emptyList(), Collections.emptyMap(), false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertNotNull(root);
+    assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+    assertTrue(root.has("fragmentInstances"));
+    assertEquals(0, root.getAsJsonArray("fragmentInstances").size());
+  }
+
+  @Test
+  public void testRenderFragmentInstanceWithStatistics() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    // Create a simple plan node tree
+    LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    // Create statistics
+    TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+    stats.setDataRegion("root.db.region1");
+    stats.setIp("127.0.0.1");
+    stats.setState("FINISHED");
+    stats.setStartTimeInMS(1000L);
+    stats.setEndTimeInMS(2000L);
+    stats.setInitDataQuerySourceCost(500000L);
+    stats.setSeqUnclosedNum(0);
+    stats.setSeqClosednNum(5);
+    stats.setUnseqUnclosedNum(1);
+    stats.setUnseqClosedNum(3);
+    stats.setReadyQueuedTime(100000L);
+    stats.setBlockQueuedTime(50000L);
+
+    TQueryStatistics queryStats = createTestQueryStatistics();
+    stats.setQueryStatistics(queryStats);
+
+    // Create operator statistics
+    TOperatorStatistics opStats = new TOperatorStatistics();
+    opStats.setOperatorType("LimitOperator");
+    opStats.setTotalExecutionTimeInNanos(300000L);
+    opStats.setOutputRows(50);
+    opStats.hasNextCalledCount = 100;
+    opStats.nextCalledCount = 50;
+    opStats.setMemoryUsage(1024);
+    opStats.setCount(10);
+    Map<String, TOperatorStatistics> operatorMap = new HashMap<>();
+    operatorMap.put("node1", opStats);
+    stats.setOperatorStatisticsMap(operatorMap);
+
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+    allStats.put(fiId, stats);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    assertEquals(1, root.get("fragmentInstancesCount").getAsInt());
+    JsonArray instances = root.getAsJsonArray("fragmentInstances");
+    assertEquals(1, instances.size());
+
+    JsonObject fi = instances.get(0).getAsJsonObject();
+    assertEquals("127.0.0.1", fi.get("ip").getAsString());
+    assertEquals("root.db.region1", fi.get("dataRegion").getAsString());
+    assertEquals("FINISHED", fi.get("state").getAsString());
+    assertEquals(1000, fi.get("totalWallTimeMs").getAsLong());
+    assertEquals(5, fi.get("seqFileClosed").getAsInt());
+    assertEquals(3, fi.get("unseqFileClosed").getAsInt());
+
+    // Verify operator tree
+    assertTrue(fi.has("operators"));
+    JsonObject operators = fi.getAsJsonObject("operators");
+    assertEquals("LimitOperator", operators.get("operatorType").getAsString());
+    assertEquals(50, operators.get("outputRows").getAsInt());
+    assertEquals(1024, operators.get("estimatedMemorySize").getAsInt());
+    assertEquals(10, operators.get("count").getAsInt());
+
+    // Verify query statistics
+    assertTrue(fi.has("queryStatistics"));
+    JsonObject qs = fi.getAsJsonObject("queryStatistics");
+    assertEquals(100, qs.get("timeSeriesIndexFilteredRows").getAsLong());
+    assertEquals(50, qs.get("chunkIndexFilteredRows").getAsLong());
+    assertEquals(25, qs.get("pageIndexFilteredRows").getAsLong());
+  }
+
+  @Test
+  public void testRenderWithVerboseQueryStatistics() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+    stats.setDataRegion("root.db.region1");
+    stats.setIp("127.0.0.1");
+    stats.setState("FINISHED");
+    stats.setStartTimeInMS(1000L);
+    stats.setEndTimeInMS(2000L);
+    stats.setInitDataQuerySourceCost(500000L);
+    stats.setSeqUnclosedNum(0);
+    stats.setSeqClosednNum(5);
+    stats.setUnseqUnclosedNum(1);
+    stats.setUnseqClosedNum(3);
+    stats.setReadyQueuedTime(100000L);
+    stats.setBlockQueuedTime(50000L);
+
+    TQueryStatistics queryStats = createVerboseQueryStatistics();
+    stats.setQueryStatistics(queryStats);
+    stats.setOperatorStatisticsMap(new HashMap<>());
+
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+    allStats.put(fiId, stats);
+
+    // Test with verbose=true to cover verbose code paths
+    String json =
+        drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, true);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+    JsonObject qs = fi.getAsJsonObject("queryStatistics");
+
+    // Verbose fields should be present
+    assertTrue(qs.has("loadBloomFilterFromCacheCount"));
+    assertTrue(qs.has("loadBloomFilterFromDiskCount"));
+    assertTrue(qs.has("loadBloomFilterActualIOSize"));
+    assertTrue(qs.has("loadBloomFilterTimeMs"));
+    assertTrue(qs.has("loadTimeSeriesMetadataFromCacheCount"));
+    assertTrue(qs.has("loadTimeSeriesMetadataFromDiskCount"));
+    assertTrue(qs.has("loadTimeSeriesMetadataActualIOSize"));
+    assertTrue(qs.has("loadChunkFromCacheCount"));
+    assertTrue(qs.has("loadChunkFromDiskCount"));
+    assertTrue(qs.has("loadChunkActualIOSize"));
+    assertTrue(qs.has("rowScanFilteredRows"));
+
+    // Non-zero disk seq counts should appear
+    assertTrue(qs.has("loadTimeSeriesMetadataDiskSeqCount"));
+    assertTrue(qs.has("loadTimeSeriesMetadataDiskUnSeqCount"));
+  }
+
+  @Test
+  public void testRenderWithNullStatistics() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    // No statistics for this instance
+    String json =
+        drawer.renderFragmentInstancesAsJson(
+            Collections.singletonList(instance), Collections.emptyMap(), false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    // Instance is filtered out because statistics is null
+    assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+  }
+
+  @Test
+  public void testRenderWithNullDataRegion() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+    // DataRegion is null -> instance should be filtered out
+    stats.setIp("127.0.0.1");
+
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+    allStats.put(fiId, stats);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    // Instance is filtered out because dataRegion is null
+    assertEquals(0, root.get("fragmentInstancesCount").getAsInt());
+  }
+
+  @Test
+  public void testRenderOperatorWithSpecifiedInfo() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    TFetchFragmentInstanceStatisticsResp stats = createBasicStats();
+
+    // Create operator with specifiedInfo
+    TOperatorStatistics opStats = new TOperatorStatistics();
+    opStats.setOperatorType("TableScanOperator");
+    opStats.setTotalExecutionTimeInNanos(500000L);
+    opStats.setOutputRows(200);
+    opStats.hasNextCalledCount = 50;
+    opStats.nextCalledCount = 25;
+    Map<String, String> specifiedInfo = new HashMap<>();
+    specifiedInfo.put("tableName", "test_table");
+    specifiedInfo.put("scanOrder", "ASC");
+    opStats.setSpecifiedInfo(specifiedInfo);
+    Map<String, TOperatorStatistics> operatorMap = new HashMap<>();
+    operatorMap.put("node1", opStats);
+    stats.setOperatorStatisticsMap(operatorMap);
+
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+    allStats.put(fiId, stats);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+    JsonObject operators = fi.getAsJsonObject("operators");
+    assertTrue(operators.has("specifiedInfo"));
+    JsonObject info = operators.getAsJsonObject("specifiedInfo");
+    assertEquals("test_table", info.get("tableName").getAsString());
+    assertEquals("ASC", info.get("scanOrder").getAsString());
+  }
+
+  @Test
+  public void testRenderOperatorWithChildren() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    // Create a plan with children: Limit -> Exchange
+    ExchangeNode exchangeNode = new ExchangeNode(new PlanNodeId("child1"));
+    LimitNode limitNode =
+        new LimitNode(new PlanNodeId("parent"), exchangeNode, 100, Optional.empty());
+
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), limitNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    TFetchFragmentInstanceStatisticsResp stats = createBasicStats();
+
+    TOperatorStatistics parentOp = new TOperatorStatistics();
+    parentOp.setOperatorType("LimitOperator");
+    parentOp.setTotalExecutionTimeInNanos(300000L);
+    parentOp.setOutputRows(50);
+    parentOp.hasNextCalledCount = 100;
+    parentOp.nextCalledCount = 50;
+
+    TOperatorStatistics childOp = new TOperatorStatistics();
+    childOp.setOperatorType("ExchangeOperator");
+    childOp.setTotalExecutionTimeInNanos(200000L);
+    childOp.setOutputRows(100);
+    childOp.hasNextCalledCount = 80;
+    childOp.nextCalledCount = 40;
+
+    Map<String, TOperatorStatistics> operatorMap = new HashMap<>();
+    operatorMap.put("parent", parentOp);
+    operatorMap.put("child1", childOp);
+    stats.setOperatorStatisticsMap(operatorMap);
+
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+    allStats.put(fiId, stats);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+    JsonObject operators = fi.getAsJsonObject("operators");
+    assertEquals("LimitOperator", operators.get("operatorType").getAsString());
+    assertTrue(operators.has("children"));
+    JsonArray children = operators.getAsJsonArray("children");
+    assertEquals(1, children.size());
+    assertEquals(
+        "ExchangeOperator", children.get(0).getAsJsonObject().get("operatorType").getAsString());
+  }
+
+  @Test
+  public void testRenderWithRetryCount() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    LimitNode planNode = new LimitNode(new PlanNodeId("node1"), null, 100, Optional.empty());
+    PlanFragment fragment = new PlanFragment(new PlanFragmentId("q", 1), planNode);
+    FragmentInstanceId fiId = new FragmentInstanceId(new PlanFragmentId("q", 1), "inst-0");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    FragmentInstance instance =
+        new FragmentInstance(fragment, fiId, null, null, 60000L, sessionInfo, false, false);
+
+    TFetchFragmentInstanceStatisticsResp stats = createBasicStats();
+    stats.setInitDataQuerySourceRetryCount(3);
+    stats.setOperatorStatisticsMap(new HashMap<>());
+
+    TQueryStatistics queryStats = createTestQueryStatistics();
+    stats.setQueryStatistics(queryStats);
+
+    Map<FragmentInstanceId, TFetchFragmentInstanceStatisticsResp> allStats = new HashMap<>();
+    allStats.put(fiId, stats);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(Collections.singletonList(instance), allStats, false);
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+
+    JsonObject fi = root.getAsJsonArray("fragmentInstances").get(0).getAsJsonObject();
+    assertTrue(fi.has("initDataQuerySourceRetryCount"));
+    assertEquals(3, fi.get("initDataQuerySourceRetryCount").getAsInt());
+  }
+
+  @Test
+  public void testJsonOutputIsValid() {
+    FragmentInstanceStatisticsJsonDrawer drawer = new FragmentInstanceStatisticsJsonDrawer();
+    MPPQueryContext context = createTestContext();
+    context.setAnalyzeCost(1000000L);
+    drawer.renderPlanStatistics(context);
+    drawer.renderDispatchCost(context);
+
+    String json =
+        drawer.renderFragmentInstancesAsJson(
+            Collections.emptyList(), Collections.emptyMap(), false);
+
+    // Verify the JSON is pretty-printed (contains newlines and indentation)
+    assertTrue(json.contains("\n"));
+    assertTrue(json.contains("  "));
+
+    // Verify it's valid JSON
+    JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+    assertNotNull(root);
+    assertTrue(root.has("planStatistics"));
+    assertTrue(root.has("fragmentInstancesCount"));
+    assertTrue(root.has("fragmentInstances"));
+  }
+
+  private MPPQueryContext createTestContext() {
+    QueryId queryId = new QueryId("test_query_id");
+    SessionInfo sessionInfo = new SessionInfo(0, "root", ZoneId.systemDefault());
+    MPPQueryContext context =
+        new MPPQueryContext(
+            "SELECT * FROM test",
+            queryId,
+            sessionInfo,
+            new org.apache.iotdb.common.rpc.thrift.TEndPoint("127.0.0.1", 6667),
+            new org.apache.iotdb.common.rpc.thrift.TEndPoint("127.0.0.1", 10730));
+    // Initialize queryPlanStatistics to avoid NPE on getAnalyzeCost() etc.
+    context.setAnalyzeCost(0);
+    return context;
+  }
+
+  private TQueryStatistics createTestQueryStatistics() {
+    TQueryStatistics qs = new TQueryStatistics();
+    qs.timeSeriesIndexFilteredRows = 100;
+    qs.chunkIndexFilteredRows = 50;
+    qs.pageIndexFilteredRows = 25;
+    qs.rowScanFilteredRows = 10;
+    qs.loadTimeSeriesMetadataFromCacheCount = 0;
+    qs.loadTimeSeriesMetadataFromDiskCount = 0;
+    qs.loadTimeSeriesMetadataActualIOSize = 0;
+    qs.loadChunkFromCacheCount = 0;
+    qs.loadChunkFromDiskCount = 0;
+    qs.loadChunkActualIOSize = 0;
+    qs.loadBloomFilterFromCacheCount = 0;
+    qs.loadBloomFilterFromDiskCount = 0;
+    qs.loadBloomFilterActualIOSize = 0;
+    qs.loadBloomFilterTime = 0;
+    return qs;
+  }
+
+  private TQueryStatistics createVerboseQueryStatistics() {
+    TQueryStatistics qs = new TQueryStatistics();
+    qs.timeSeriesIndexFilteredRows = 100;
+    qs.chunkIndexFilteredRows = 50;
+    qs.pageIndexFilteredRows = 25;
+    qs.rowScanFilteredRows = 10;
+
+    // Bloom filter stats
+    qs.loadBloomFilterFromCacheCount = 5;
+    qs.loadBloomFilterFromDiskCount = 3;
+    qs.loadBloomFilterActualIOSize = 1024;
+    qs.loadBloomFilterTime = 500000; // 0.5ms
+
+    // TimeSeries metadata stats
+    qs.loadTimeSeriesMetadataDiskSeqCount = 10;
+    qs.loadTimeSeriesMetadataDiskUnSeqCount = 5;
+    qs.loadTimeSeriesMetadataMemSeqCount = 8;
+    qs.loadTimeSeriesMetadataMemUnSeqCount = 3;
+    qs.loadTimeSeriesMetadataDiskSeqTime = 1000000; // 1ms
+    qs.loadTimeSeriesMetadataDiskUnSeqTime = 500000; // 0.5ms
+    qs.loadTimeSeriesMetadataMemSeqTime = 200000;
+    qs.loadTimeSeriesMetadataMemUnSeqTime = 100000;
+
+    // Aligned metadata stats
+    qs.loadTimeSeriesMetadataAlignedDiskSeqCount = 2;
+    qs.loadTimeSeriesMetadataAlignedDiskUnSeqCount = 1;
+    qs.loadTimeSeriesMetadataAlignedMemSeqCount = 4;
+    qs.loadTimeSeriesMetadataAlignedMemUnSeqCount = 2;
+    qs.loadTimeSeriesMetadataAlignedDiskSeqTime = 300000;
+    qs.loadTimeSeriesMetadataAlignedDiskUnSeqTime = 150000;
+    qs.loadTimeSeriesMetadataAlignedMemSeqTime = 100000;
+    qs.loadTimeSeriesMetadataAlignedMemUnSeqTime = 50000;
+
+    qs.loadTimeSeriesMetadataFromCacheCount = 20;
+    qs.loadTimeSeriesMetadataFromDiskCount = 15;
+    qs.loadTimeSeriesMetadataActualIOSize = 4096;
+
+    // Chunk reader stats
+    qs.constructNonAlignedChunkReadersDiskCount = 5;
+    qs.constructNonAlignedChunkReadersMemCount = 3;
+    qs.constructAlignedChunkReadersDiskCount = 2;
+    qs.constructAlignedChunkReadersMemCount = 1;
+    qs.constructNonAlignedChunkReadersDiskTime = 200000;
+    qs.constructNonAlignedChunkReadersMemTime = 100000;
+    qs.constructAlignedChunkReadersDiskTime = 150000;
+    qs.constructAlignedChunkReadersMemTime = 75000;
+
+    // Chunk stats
+    qs.loadChunkFromCacheCount = 10;
+    qs.loadChunkFromDiskCount = 8;
+    qs.loadChunkActualIOSize = 2048;
+
+    // Page reader stats
+    qs.pageReadersDecodeAlignedDiskCount = 6;
+    qs.pageReadersDecodeAlignedDiskTime = 300000;
+    qs.pageReadersDecodeAlignedMemCount = 4;
+    qs.pageReadersDecodeAlignedMemTime = 200000;
+    qs.pageReadersDecodeNonAlignedDiskCount = 3;
+    qs.pageReadersDecodeNonAlignedDiskTime = 150000;
+    qs.pageReadersDecodeNonAlignedMemCount = 2;
+    qs.pageReadersDecodeNonAlignedMemTime = 100000;
+    qs.pageReaderMaxUsedMemorySize = 65536;
+    qs.chunkWithMetadataErrorsCount = 1;
+
+    return qs;
+  }
+
+  private TFetchFragmentInstanceStatisticsResp createBasicStats() {
+    TFetchFragmentInstanceStatisticsResp stats = new TFetchFragmentInstanceStatisticsResp();
+    stats.setDataRegion("root.db.region1");
+    stats.setIp("127.0.0.1");
+    stats.setState("FINISHED");
+    stats.setStartTimeInMS(1000L);
+    stats.setEndTimeInMS(2000L);
+    stats.setInitDataQuerySourceCost(500000L);
+    stats.setSeqUnclosedNum(0);
+    stats.setSeqClosednNum(5);
+    stats.setUnseqUnclosedNum(1);
+    stats.setUnseqClosedNum(3);
+    stats.setReadyQueuedTime(100000L);
+    stats.setBlockQueuedTime(50000L);
+
+    TQueryStatistics queryStats = createTestQueryStatistics();
+    stats.setQueryStatistics(queryStats);
+    return stats;
+  }
+}
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 93dfa8bb1f4..eac3a68361e 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
@@ -938,8 +938,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