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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new cecd3dc8 feat(datafusion): add SHOW, ADD and DROP PARTITION for 
catalog-managed format tables (#816)
cecd3dc8 is described below

commit cecd3dc80512734311946fe2ab37b1f399bd7d6c
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Sun Sep 13 13:22:15 2026 +0800

    feat(datafusion): add SHOW, ADD and DROP PARTITION for catalog-managed 
format tables (#816)
---
 .../datafusion/src/format_partition_ddl.rs         | 570 +++++++++++++++++++++
 crates/integrations/datafusion/src/lib.rs          |   1 +
 crates/integrations/datafusion/src/sql_context.rs  | 277 +++++++---
 .../datafusion/tests/rest_format_partition_sql.rs  | 497 ++++++++++++++++++
 crates/paimon/src/catalog/mod.rs                   |  12 +
 crates/paimon/src/catalog/rest/rest_catalog.rs     |  36 ++
 crates/paimon/src/table/format_partition.rs        |  16 +-
 crates/paimon/src/table/mod.rs                     |   3 +
 crates/paimon/tests/mock_server.rs                 |  20 +
 crates/paimon/tests/rest_catalog_test.rs           |  99 ++++
 docs/src/sql.md                                    |  72 ++-
 11 files changed, 1520 insertions(+), 83 deletions(-)

diff --git a/crates/integrations/datafusion/src/format_partition_ddl.rs 
b/crates/integrations/datafusion/src/format_partition_ddl.rs
new file mode 100644
index 00000000..b611c779
--- /dev/null
+++ b/crates/integrations/datafusion/src/format_partition_ddl.rs
@@ -0,0 +1,570 @@
+// 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.
+
+//! SHOW, ADD and DROP PARTITION for Format Tables with catalog-managed 
partitions.
+//! DROP PARTITION on a Paimon table stays in `SQLContext`, where it is a 
snapshot commit.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+use datafusion::arrow::array::StringArray;
+use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
+use datafusion::arrow::record_batch::RecordBatch;
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::prelude::DataFrame;
+use datafusion::sql::sqlparser::ast::{
+    Expr as SqlExpr, ObjectName, Partition as SqlPartition, Value as SqlValue,
+};
+use datafusion::sql::sqlparser::dialect::GenericDialect;
+use datafusion::sql::sqlparser::keywords::Keyword;
+use datafusion::sql::sqlparser::parser::Parser;
+use datafusion::sql::sqlparser::tokenizer::{Token, Tokenizer};
+use paimon::catalog::{Catalog, Identifier};
+use paimon::spec::{CoreOptions, DataType as PaimonDataType};
+use paimon::table::{
+    format_partition_value, parse_format_partition_value, 
FormatTablePartitionPaths,
+};
+
+use crate::error::to_datafusion_error;
+use crate::sql_context::{is_table_not_exist, ok_result, partition_assignment, 
SQLContext};
+
+#[derive(Debug)]
+pub(crate) struct ShowPartitionsStatement {
+    table_name: ObjectName,
+    partition_filter: Vec<SqlExpr>,
+}
+
+pub(crate) fn parse_show_partitions(sql: &str) -> 
DFResult<Option<ShowPartitionsStatement>> {
+    let dialect = GenericDialect {};
+    let tokens = Tokenizer::new(&dialect, sql)
+        .tokenize_with_location()
+        .map_err(sql_parse_error)?;
+    let significant = tokens
+        .iter()
+        .filter_map(|token| match &token.token {
+            Token::Whitespace(_) => None,
+            Token::Word(word) => Some(word.keyword),
+            _ => Some(Keyword::NoKeyword),
+        })
+        .take(2)
+        .collect::<Vec<_>>();
+    if !matches!(significant.as_slice(), [Keyword::SHOW, Keyword::PARTITIONS]) 
{
+        return Ok(None);
+    }
+
+    let mut parser = Parser::new(&dialect).with_tokens_with_locations(tokens);
+    parser
+        .expect_keyword_is(Keyword::SHOW)
+        .map_err(sql_parse_error)?;
+    parser
+        .expect_keyword_is(Keyword::PARTITIONS)
+        .map_err(sql_parse_error)?;
+    let table_name = parser.parse_object_name(false).map_err(sql_parse_error)?;
+    let partition_filter = if parser.parse_keyword(Keyword::PARTITION) {
+        parser
+            .expect_token(&Token::LParen)
+            .map_err(sql_parse_error)?;
+        let expressions = parser
+            .parse_comma_separated(Parser::parse_expr)
+            .map_err(sql_parse_error)?;
+        parser
+            .expect_token(&Token::RParen)
+            .map_err(sql_parse_error)?;
+        expressions
+    } else {
+        Vec::new()
+    };
+    let _ = parser.consume_token(&Token::SemiColon);
+    if parser.peek_token().token != Token::EOF {
+        return Err(DataFusionError::Plan(format!(
+            "SQL parse error: unexpected token {} after SHOW PARTITIONS 
statement",
+            parser.peek_token().token
+        )));
+    }
+    Ok(Some(ShowPartitionsStatement {
+        table_name,
+        partition_filter,
+    }))
+}
+
+fn sql_parse_error(error: impl std::fmt::Display) -> DataFusionError {
+    DataFusionError::Plan(format!("SQL parse error: {error}"))
+}
+
+pub(crate) async fn execute_show_partitions(
+    ctx: &SQLContext,
+    show_partitions: &ShowPartitionsStatement,
+    enable_ident_normalization: bool,
+) -> DFResult<DataFrame> {
+    SQLContext::ensure_partition_command_target(&show_partitions.table_name, 
"SHOW PARTITIONS")?;
+    let (catalog, _catalog_name, identifier) =
+        ctx.resolve_catalog_and_table(&show_partitions.table_name)?;
+    let table = catalog
+        .get_table(&identifier)
+        .await
+        .map_err(to_datafusion_error)?;
+    ensure_catalog_managed_format_table(&table, "SHOW PARTITIONS")?;
+    let filter = if show_partitions.partition_filter.is_empty() {
+        None
+    } else {
+        let spec = parse_format_partition_spec(
+            &show_partitions.partition_filter,
+            &table,
+            false,
+            None,
+            enable_ident_normalization,
+        )?;
+        Some(display_partition_values(&spec, &table)?)
+    };
+
+    let partition_paths = FormatTablePartitionPaths::new(
+        table.schema().partition_keys().iter().cloned(),
+        
CoreOptions::new(table.schema().options()).format_table_partition_only_value_in_path(),
+    );
+    let mut names = Vec::new();
+    for partition in catalog
+        .list_partitions(&identifier)
+        .await
+        .map_err(to_datafusion_error)?
+    {
+        let values = display_partition_values(&partition.spec, &table)?;
+        if !filter.as_ref().is_none_or(|filter| {
+            filter
+                .iter()
+                .all(|(key, value)| values.get(key) == Some(value))
+        }) {
+            continue;
+        }
+        let display_spec = values
+            .into_iter()
+            .map(|(key, value)| (key, value.unwrap_or_else(|| 
"null".to_string())))
+            .collect();
+        let name = partition_paths
+            .partition_name(&display_spec)
+            .map_err(to_datafusion_error)?;
+        names.push(name);
+    }
+    names.sort();
+
+    let schema = Arc::new(Schema::new(vec![Field::new(
+        "partition",
+        ArrowDataType::Utf8,
+        false,
+    )]));
+    let batch = RecordBatch::try_new(schema, 
vec![Arc::new(StringArray::from(names))])?;
+    ctx.ctx().read_batch(batch)
+}
+
+pub(crate) async fn execute_add_partitions(
+    ctx: &SQLContext,
+    catalog: &Arc<dyn Catalog>,
+    identifier: &Identifier,
+    partitions: &[SqlPartition],
+    ignore_if_exists: bool,
+    ignore_if_table_not_exists: bool,
+    enable_ident_normalization: bool,
+) -> DFResult<DataFrame> {
+    let table = match catalog.get_table(identifier).await {
+        Ok(table) => table,
+        Err(error) if ignore_if_table_not_exists && is_table_not_exist(&error) 
=> {
+            return ok_result(ctx.ctx());
+        }
+        Err(error) => return Err(to_datafusion_error(error)),
+    };
+    ensure_catalog_managed_format_table(&table, "ALTER TABLE ADD PARTITION")?;
+    if partitions.is_empty() {
+        return Err(DataFusionError::Plan(
+            "ADD PARTITION requires at least one partition 
specification".to_string(),
+        ));
+    }
+
+    let core_options = CoreOptions::new(table.schema().options());
+    let partition_paths = FormatTablePartitionPaths::new(
+        table.schema().partition_keys().iter().cloned(),
+        core_options.format_table_partition_only_value_in_path(),
+    );
+    let table_path = table.location();
+    let mut specs = Vec::with_capacity(partitions.len());
+    let mut directories = Vec::with_capacity(partitions.len());
+    for partition in partitions {
+        let expressions = match partition {
+            SqlPartition::Partitions(expressions) => expressions,
+            other => {
+                return Err(DataFusionError::Plan(format!(
+                    "Unsupported ADD PARTITION specification: {other}"
+                )))
+            }
+        };
+        let spec = parse_format_partition_spec(
+            expressions,
+            &table,
+            true,
+            Some("ADD PARTITION"),
+            enable_ident_normalization,
+        )?;
+        let relative_path = partition_paths
+            .relative_path(&spec)
+            .map_err(to_datafusion_error)?;
+        directories.push(format!(
+            "{}/{}",
+            table_path.trim_end_matches('/'),
+            relative_path
+        ));
+        specs.push(spec);
+    }
+
+    catalog
+        .create_partitions(identifier, specs, ignore_if_exists)
+        .await
+        .map_err(to_datafusion_error)?;
+    for directory in directories {
+        table
+            .file_io()
+            .mkdirs(&directory)
+            .await
+            .map_err(to_datafusion_error)?;
+    }
+    ok_result(ctx.ctx())
+}
+
+/// Unregister catalog-managed partitions and then delete their directories.
+/// A partial specification, keys in any position, drops every registered 
partition it matches.
+pub(crate) async fn drop_catalog_managed_partitions(
+    ctx: &SQLContext,
+    catalog: &Arc<dyn Catalog>,
+    identifier: &Identifier,
+    table: &paimon::Table,
+    requests: &[(&[SqlExpr], bool)],
+    enable_ident_normalization: bool,
+) -> DFResult<DataFrame> {
+    ensure_catalog_managed_format_table(table, "ALTER TABLE DROP PARTITION")?;
+    let partition_key_count = table.schema().partition_keys().len();
+    let mut requested = Vec::with_capacity(requests.len());
+    for (expressions, ignore_if_not_exists) in requests {
+        let spec = parse_format_partition_spec(
+            expressions,
+            table,
+            false,
+            Some("DROP PARTITION"),
+            enable_ident_normalization,
+        )?;
+        requested.push((spec, *ignore_if_not_exists));
+    }
+
+    // Only a partial specification needs the whole registry; complete ones 
are looked up by
+    // name, which keeps the common exact drop from reading every registration.
+    let complete_specs = requested
+        .iter()
+        .filter(|(spec, _)| spec.len() == partition_key_count)
+        .map(|(spec, _)| spec.clone())
+        .collect::<Vec<_>>();
+    let registered = if complete_specs.len() == requested.len() {
+        catalog
+            .list_partitions_by_names(identifier, complete_specs)
+            .await
+    } else {
+        catalog.list_partitions(identifier).await
+    }
+    .map_err(to_datafusion_error)?;
+
+    let core_options = CoreOptions::new(table.schema().options());
+    let partition_paths = FormatTablePartitionPaths::new(
+        table.schema().partition_keys().iter().cloned(),
+        core_options.format_table_partition_only_value_in_path(),
+    );
+    let table_path = table.location().trim_end_matches('/');
+    // Every directory is resolved before the first mutation, so a 
registration that cannot
+    // be turned into a path fails the statement as a whole.
+    let registered = registered
+        .into_iter()
+        .map(|partition| {
+            let relative_path = partition_paths
+                .relative_path(&partition.spec)
+                .map_err(to_datafusion_error)?;
+            let custom_located = has_custom_location(&partition);
+            Ok((
+                partition.spec,
+                format!("{table_path}/{relative_path}"),
+                custom_located,
+            ))
+        })
+        .collect::<DFResult<Vec<_>>>()?;
+
+    let mut selected: Vec<(HashMap<String, String>, String, bool)> = 
Vec::new();
+    let mut selected_paths = HashSet::new();
+    for (spec, ignore_if_not_exists) in &requested {
+        // Values are compared as the catalog holds them, so a partition 
registered as
+        // `month=01` is not the partition `month = 1` names.
+        let mut matched = false;
+        for (registered_spec, path, custom_located) in &registered {
+            if !spec
+                .iter()
+                .all(|(key, value)| registered_spec.get(key) == Some(value))
+            {
+                continue;
+            }
+            matched = true;
+            if selected_paths.insert(path.clone()) {
+                selected.push((registered_spec.clone(), path.clone(), 
*custom_located));
+            }
+        }
+        // Only a complete specification can be reported as missing; a partial 
one may match
+        // nothing, as in Java.
+        if !matched && spec.len() == partition_key_count && 
!ignore_if_not_exists {
+            return Err(DataFusionError::Plan(format!(
+                "Partition {spec:?} does not exist in table {}",
+                identifier.full_name()
+            )));
+        }
+    }
+
+    if selected.is_empty() {
+        return ok_result(ctx.ctx());
+    }
+
+    catalog
+        .drop_partitions(
+            identifier,
+            selected.iter().map(|(spec, _, _)| spec.clone()).collect(),
+        )
+        .await
+        .map_err(to_datafusion_error)?;
+    for (_, path, custom_located) in selected {
+        // A partition registered at a location of its own is only 
unregistered, as in Java,
+        // and keeps its data there.
+        if custom_located {
+            continue;
+        }
+        table
+            .file_io()
+            .delete_dir(&path)
+            .await
+            .map_err(to_datafusion_error)?;
+    }
+    ok_result(ctx.ctx())
+}
+
+/// Whether the catalog registered a partition at a location of its own rather 
than under the
+/// table directory.
+fn has_custom_location(partition: &paimon::spec::Partition) -> bool {
+    partition
+        .options
+        .as_ref()
+        .is_some_and(|options| options.contains_key("path"))
+}
+
+pub(crate) fn ensure_catalog_managed_format_table(
+    table: &paimon::Table,
+    operation: &str,
+) -> DFResult<()> {
+    if table.schema().partition_keys().is_empty() {
+        return Err(DataFusionError::Plan(format!(
+            "{operation} requires a partitioned table, but {} is not 
partitioned",
+            table.identifier().full_name()
+        )));
+    }
+    if !table.has_catalog_managed_partitions() {
+        return Err(DataFusionError::Plan(format!(
+            "{operation} is supported only for catalog-managed partitions on 
an internal Format Table loaded from REST Catalog; table {} does not have this 
configuration",
+            table.identifier().full_name()
+        )));
+    }
+    Ok(())
+}
+
+/// `mutating_operation` names the statement when it changes partitions (ADD 
or DROP PARTITION),
+/// which refuses a blank string for a string partition column.
+fn parse_format_partition_spec(
+    exprs: &[SqlExpr],
+    table: &paimon::Table,
+    require_complete: bool,
+    mutating_operation: Option<&str>,
+    enable_ident_normalization: bool,
+) -> DFResult<HashMap<String, String>> {
+    let fields = table
+        .schema()
+        .fields()
+        .iter()
+        .map(|field| (field.name(), field))
+        .collect::<HashMap<_, _>>();
+    let partition_keys = table.schema().partition_keys();
+    let options = CoreOptions::new(table.schema().options());
+    let mut spec = HashMap::with_capacity(exprs.len());
+
+    for expr in exprs {
+        let (column, literal) = partition_assignment(expr, 
enable_ident_normalization)?;
+        if !partition_keys.contains(&column) {
+            return Err(DataFusionError::Plan(format!(
+                "Column '{column}' is not a partition column"
+            )));
+        }
+        if spec.contains_key(&column) {
+            return Err(DataFusionError::Plan(format!(
+                "Duplicate partition column '{column}'"
+            )));
+        }
+        let field = fields.get(column.as_str()).ok_or_else(|| {
+            DataFusionError::Plan(format!("Column '{column}' not found in 
table schema"))
+        })?;
+        let data_type = field.data_type();
+        let value = match partition_literal_to_string(literal)? {
+            None => options.partition_default_name().to_string(),
+            // Read with the column type as Java `TypeUtils.castFromString` 
does, then written back
+            // in the spelling ADD PARTITION registers.
+            Some(text) => {
+                let text = match data_type {
+                    PaimonDataType::Char(_) | PaimonDataType::VarChar(_) => {
+                        // A blank string would address the default partition; 
Java
+                        // `PaimonFormatTable.requireNameablePartitionValues` 
refuses it too.
+                        if let Some(operation) = mutating_operation {
+                            if text.trim().is_empty() {
+                                return Err(DataFusionError::Plan(format!(
+                                    "{operation} does not support an empty or 
whitespace-only \
+                                     string for partition column '{column}' of 
Format Table {}. \
+                                     Such a value is written to the partition 
named {}, name it \
+                                     directly to address it",
+                                    table.identifier().full_name(),
+                                    options.partition_default_name()
+                                )));
+                            }
+                        }
+                        text
+                    }
+                    _ => text.trim().to_string(),
+                };
+                parse_format_partition_value(&text, data_type)
+                    .and_then(|datum| {
+                        format_partition_value(
+                            &datum,
+                            data_type,
+                            options.partition_default_name(),
+                            options.legacy_partition_name(),
+                        )
+                    })
+                    .ok_or_else(|| {
+                        DataFusionError::Plan(format!(
+                            "Cannot use {literal} as a value of partition 
column '{column}' \
+                             with type {data_type:?}"
+                        ))
+                    })?
+            }
+        };
+        spec.insert(column, value);
+    }
+
+    if require_complete {
+        let missing = partition_keys
+            .iter()
+            .filter(|key| !spec.contains_key(key.as_str()))
+            .cloned()
+            .collect::<Vec<_>>();
+        if !missing.is_empty() {
+            return Err(DataFusionError::Plan(format!(
+                "Incomplete partition spec: missing keys [{}]",
+                missing.join(", ")
+            )));
+        }
+    }
+    Ok(spec)
+}
+
+/// The string a SQL partition literal stands for, as Spark hands it to 
Paimon; `None` is NULL.
+/// A number is canonical, so `month = 01` and `month = 1` name the same 
partition.
+fn partition_literal_to_string(expr: &SqlExpr) -> DFResult<Option<String>> {
+    use datafusion::sql::sqlparser::ast::{DataType as SqlDataType, 
UnaryOperator};
+
+    let unsupported =
+        || DataFusionError::Plan(format!("Unsupported partition value 
expression: {expr}"));
+    let (sign, value, signed) = match expr {
+        SqlExpr::UnaryOp {
+            op: UnaryOperator::Minus,
+            expr,
+        } => ("-", expr.as_ref(), true),
+        SqlExpr::UnaryOp {
+            op: UnaryOperator::Plus,
+            expr,
+        } => ("", expr.as_ref(), true),
+        other => ("", other, false),
+    };
+    match value {
+        SqlExpr::Value(value) => match &value.value {
+            SqlValue::Number(number, _) => {
+                let canonical = number
+                    .parse::<i128>()
+                    .map_or_else(|_| number.clone(), |number| 
number.to_string());
+                Ok(Some(format!("{sign}{canonical}")))
+            }
+            _ if signed => Err(unsupported()),
+            SqlValue::Null => Ok(None),
+            SqlValue::Boolean(value) => Ok(Some(value.to_string())),
+            other => other
+                .clone()
+                .into_string()
+                .map(Some)
+                .ok_or_else(unsupported),
+        },
+        SqlExpr::TypedString(typed) if !signed && matches!(typed.data_type, 
SqlDataType::Date) => {
+            typed
+                .value
+                .value
+                .clone()
+                .into_string()
+                .map(Some)
+                .ok_or_else(unsupported)
+        }
+        _ => Err(unsupported()),
+    }
+}
+
+/// The values of a partition spec as SHOW PARTITIONS prints and filters them: 
read with the column
+/// type, so `month=01` and `month=1` are the same partition, and `None` for 
the default partition.
+fn display_partition_values(
+    spec: &HashMap<String, String>,
+    table: &paimon::Table,
+) -> DFResult<HashMap<String, Option<String>>> {
+    let options = CoreOptions::new(table.schema().options());
+    let default_partition_name = options.partition_default_name();
+    let fields = table.schema().partition_fields();
+    spec.iter()
+        .map(|(key, raw)| {
+            if raw == default_partition_name {
+                return Ok((key.clone(), None));
+            }
+            let data_type = fields
+                .iter()
+                .find(|field| field.name() == key)
+                .map(|field| field.data_type())
+                .ok_or_else(|| {
+                    DataFusionError::Plan(format!(
+                        "Invalid partition spec {spec:?} for table {}",
+                        table.identifier().full_name()
+                    ))
+                })?;
+            parse_format_partition_value(raw, data_type)
+                .and_then(|datum| {
+                    format_partition_value(&datum, data_type, 
default_partition_name, false)
+                })
+                .map(|value| (key.clone(), Some(value)))
+                .ok_or_else(|| {
+                    DataFusionError::Plan(format!(
+                        "Invalid catalog partition value {raw:?} for column 
'{key}' with type \
+                         {data_type:?}"
+                    ))
+                })
+        })
+        .collect()
+}
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index 985ad31f..4ec76c45 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -43,6 +43,7 @@ mod catalog;
 mod delete;
 mod error;
 mod filter_pushdown;
+mod format_partition_ddl;
 #[cfg(feature = "fulltext")]
 mod full_text_search;
 mod hybrid_search;
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 74a0df45..b75534e9 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -34,7 +34,9 @@
 //! - `ALTER TABLE db.t ALTER COLUMN col TYPE new_type`
 //! - `ALTER TABLE db.t ALTER COLUMN col SET|DROP NOT NULL`
 //! - `ALTER TABLE db.t RENAME TO new_name`
-//! - `ALTER TABLE db.t DROP PARTITION (col = val, ...)`
+//! - `ALTER TABLE db.t ADD [IF NOT EXISTS] PARTITION (...) [PARTITION (...)]`
+//! - `ALTER TABLE db.t DROP [IF EXISTS] PARTITION (...)`
+//! - `SHOW PARTITIONS db.t [PARTITION (...)]`
 //! - `CREATE VIEW [IF NOT EXISTS] view [(col, ...)] AS query`
 //! - `DROP VIEW [IF EXISTS] view`
 //! - `CREATE FUNCTION name(args) RETURNS type [LANGUAGE SQL] RETURN 
expression`
@@ -72,11 +74,11 @@ use datafusion::sql::sqlparser::keywords::Keyword;
 use datafusion::sql::sqlparser::parser::Parser;
 use datafusion::sql::sqlparser::tokenizer::{Token, Tokenizer};
 use futures::StreamExt;
-use paimon::catalog::{parse_object_name, Catalog, Identifier};
+use paimon::catalog::{parse_object_name, Catalog, Identifier, 
ParsedObjectName};
 use paimon::spec::{
     ArrayType as PaimonArrayType, BigIntType, BinaryType, BlobType, 
BooleanType, CharType,
-    DataField as PaimonDataField, DataType as PaimonDataType, DateType, Datum, 
DecimalType,
-    DoubleType, FloatType, IntType, LocalZonedTimestampType, MapType as 
PaimonMapType,
+    CoreOptions, DataField as PaimonDataField, DataType as PaimonDataType, 
DateType, Datum,
+    DecimalType, DoubleType, FloatType, IntType, LocalZonedTimestampType, 
MapType as PaimonMapType,
     RowType as PaimonRowType, SchemaChange, SmallIntType, TimestampType, 
TinyIntType,
     VarBinaryType, VarCharType, VariantType,
 };
@@ -452,6 +454,16 @@ impl SQLContext {
             // Time-travel queries are not DDL; skip our own parsing and 
handle directly.
             return self.handle_time_travel_query(&rewritten_sql).await;
         }
+        if let Some(show_partitions) =
+            crate::format_partition_ddl::parse_show_partitions(&rewritten_sql)?
+        {
+            return crate::format_partition_ddl::execute_show_partitions(
+                self,
+                &show_partitions,
+                enable_ident_normalization,
+            )
+            .await;
+        }
 
         let statements = parse_sql_statements(&rewritten_sql)?;
 
@@ -524,6 +536,15 @@ impl SQLContext {
                 obj_name,
             } => self.handle_show_create_table(sql, obj_name).await,
             Statement::AlterTable(alter_table) => {
+                if alter_table.location.is_some()
+                    && alter_table.operations.iter().any(|operation| {
+                        matches!(operation, AlterTableOperation::AddPartitions 
{ .. })
+                    })
+                {
+                    return Err(DataFusionError::Plan(
+                        "LOCATION is not supported for Format Table 
partitions".to_string(),
+                    ));
+                }
                 let (catalog, _catalog_name, _) =
                     self.resolve_catalog_and_table(&alter_table.name)?;
                 self.handle_alter_table(
@@ -1262,20 +1283,64 @@ impl SQLContext {
         if_exists: bool,
         enable_ident_normalization: bool,
     ) -> DFResult<DataFrame> {
-        Self::ensure_main_branch_write_target(name, "ALTER TABLE")?;
+        let has_partition_operation = operations.iter().any(|operation| {
+            matches!(
+                operation,
+                AlterTableOperation::AddPartitions { .. }
+                    | AlterTableOperation::DropPartitions { .. }
+            )
+        });
+        if has_partition_operation {
+            Self::ensure_partition_command_target(name, "ALTER TABLE")?;
+        } else {
+            Self::ensure_main_branch_write_target(name, "ALTER TABLE")?;
+        }
         let identifier = self.resolve_table_name(name)?;
 
         if operations.len() > 1
-            && operations.iter().any(|operation| {
-                matches!(
-                    operation,
-                    AlterTableOperation::RenameTable { .. }
-                        | AlterTableOperation::DropPartitions { .. }
-                )
+            && operations
+                .iter()
+                .any(|operation| matches!(operation, 
AlterTableOperation::RenameTable { .. }))
+        {
+            return Err(DataFusionError::Plan(
+                "ALTER TABLE RENAME TO must be used alone".to_string(),
+            ));
+        }
+        // A statement may drop several partitions, but only partitions: 
mixing the drop
+        // with schema changes would commit two unrelated changes under one 
statement.
+        let drop_partition_requests = operations
+            .iter()
+            .filter_map(|operation| match operation {
+                AlterTableOperation::DropPartitions {
+                    partitions,
+                    if_exists: partition_if_exists,
+                } => Some((partitions.as_slice(), *partition_if_exists)),
+                _ => None,
             })
+            .collect::<Vec<_>>();
+        if !drop_partition_requests.is_empty() {
+            if drop_partition_requests.len() != operations.len() {
+                return Err(DataFusionError::Plan(
+                    "ALTER TABLE DROP PARTITION must be used 
alone".to_string(),
+                ));
+            }
+            return self
+                .handle_drop_partitions(
+                    catalog,
+                    &identifier,
+                    &drop_partition_requests,
+                    if_exists,
+                    enable_ident_normalization,
+                )
+                .await;
+        }
+        if operations
+            .iter()
+            .any(|operation| matches!(operation, 
AlterTableOperation::AddPartitions { .. }))
+            && operations.len() != 1
         {
             return Err(DataFusionError::Plan(
-                "ALTER TABLE RENAME TO and DROP PARTITION must be used 
alone".to_string(),
+                "ALTER TABLE ADD PARTITION cannot be combined with other 
operations".to_string(),
             ));
         }
 
@@ -1335,19 +1400,20 @@ impl SQLContext {
                         }
                     }
                 }
-                AlterTableOperation::DropPartitions {
-                    partitions,
-                    if_exists: partition_if_exists,
+                AlterTableOperation::AddPartitions {
+                    if_not_exists,
+                    new_partitions,
                 } => {
-                    return self
-                        .handle_drop_partitions(
-                            catalog,
-                            &identifier,
-                            partitions,
-                            if_exists || *partition_if_exists,
-                            enable_ident_normalization,
-                        )
-                        .await;
+                    return crate::format_partition_ddl::execute_add_partitions(
+                        self,
+                        catalog,
+                        &identifier,
+                        new_partitions,
+                        *if_not_exists,
+                        if_exists,
+                        enable_ident_normalization,
+                    )
+                    .await;
                 }
                 other => {
                     return Err(DataFusionError::Plan(format!(
@@ -1930,32 +1996,57 @@ impl SQLContext {
         &self,
         catalog: &Arc<dyn Catalog>,
         identifier: &Identifier,
-        partitions: &[SqlExpr],
-        if_exists: bool,
+        requests: &[(&[SqlExpr], bool)],
+        ignore_if_table_not_exists: bool,
         enable_ident_normalization: bool,
     ) -> DFResult<DataFrame> {
-        if partitions.is_empty() {
+        if requests
+            .iter()
+            .any(|(expressions, _)| expressions.is_empty())
+        {
             return Err(DataFusionError::Plan(
-                "DROP PARTITIONS requires at least one partition 
specification".to_string(),
+                "DROP PARTITION requires a partition 
specification".to_string(),
             ));
         }
         let table = match catalog.get_table(identifier).await {
-            Ok(t) => t,
-            Err(e) if if_exists && is_table_not_exist(&e) => {
+            Ok(table) => table,
+            Err(error) if ignore_if_table_not_exists && 
is_table_not_exist(&error) => {
                 return ok_result(&self.ctx);
             }
-            Err(e) => return Err(to_datafusion_error(e)),
+            Err(error) => return Err(to_datafusion_error(error)),
         };
 
-        let partition_values = parse_partition_values(
-            partitions,
-            table.schema().fields(),
-            table.schema().partition_keys(),
-            enable_ident_normalization,
-        )?;
+        if table.has_catalog_managed_partitions() {
+            return 
crate::format_partition_ddl::drop_catalog_managed_partitions(
+                self,
+                catalog,
+                identifier,
+                &table,
+                requests,
+                enable_ident_normalization,
+            )
+            .await;
+        }
+        if CoreOptions::new(table.schema().options()).is_format_table() {
+            crate::format_partition_ddl::ensure_catalog_managed_format_table(
+                &table,
+                "ALTER TABLE DROP PARTITION",
+            )?;
+        }
 
-        let wb = table.new_write_builder();
-        let commit = wb.try_new_commit().map_err(to_datafusion_error)?;
+        let mut partition_values = Vec::with_capacity(requests.len());
+        for (expressions, _) in requests {
+            partition_values.extend(parse_partition_values(
+                expressions,
+                table.schema().fields(),
+                table.schema().partition_keys(),
+                enable_ident_normalization,
+            )?);
+        }
+        let commit = table
+            .new_write_builder()
+            .try_new_commit()
+            .map_err(to_datafusion_error)?;
         commit
             .truncate_partitions(partition_values)
             .await
@@ -2099,7 +2190,7 @@ impl SQLContext {
     }
 
     /// Resolve an ObjectName like `catalog.db.table` or `db.table` to a 
catalog and Identifier.
-    fn resolve_catalog_and_table(
+    pub(crate) fn resolve_catalog_and_table(
         &self,
         name: &ObjectName,
     ) -> DFResult<(Arc<dyn Catalog>, String, Identifier)> {
@@ -2149,13 +2240,7 @@ impl SQLContext {
     }
 
     fn ensure_main_branch_write_target(name: &ObjectName, operation: &str) -> 
DFResult<()> {
-        let object = name
-            .0
-            .last()
-            .and_then(|part| part.as_ident())
-            .map(|ident| ident.value.as_str())
-            .ok_or_else(|| DataFusionError::Plan(format!("Invalid table 
reference: {name}")))?;
-        let parsed = parse_object_name(object).map_err(to_datafusion_error)?;
+        let parsed = Self::parse_target_object_name(name)?;
         if let Some(branch) = parsed.branch() {
             return Err(DataFusionError::NotImplemented(format!(
                 "{operation} on Paimon branch '{branch}' is not supported"
@@ -2164,6 +2249,34 @@ impl SQLContext {
         Ok(())
     }
 
+    pub(crate) fn ensure_partition_command_target(
+        name: &ObjectName,
+        operation: &str,
+    ) -> DFResult<()> {
+        let parsed = Self::parse_target_object_name(name)?;
+        if let Some(branch) = parsed.branch() {
+            return Err(DataFusionError::NotImplemented(format!(
+                "{operation} on Paimon branch '{branch}' is not supported"
+            )));
+        }
+        if let Some(system_table) = parsed.system_table() {
+            return Err(DataFusionError::NotImplemented(format!(
+                "{operation} on Paimon system table '{system_table}' is not 
supported"
+            )));
+        }
+        Ok(())
+    }
+
+    fn parse_target_object_name(name: &ObjectName) -> 
DFResult<ParsedObjectName> {
+        let object = name
+            .0
+            .last()
+            .and_then(|part| part.as_ident())
+            .map(|ident| ident.value.as_str())
+            .ok_or_else(|| DataFusionError::Plan(format!("Invalid table 
reference: {name}")))?;
+        parse_object_name(object).map_err(to_datafusion_error)
+    }
+
     /// Resolve an ObjectName to just the Identifier (for backward compat in 
handle_alter_table).
     fn resolve_table_name(&self, name: &ObjectName) -> DFResult<Identifier> {
         let (_catalog, _catalog_name, identifier) = 
self.resolve_catalog_and_table(name)?;
@@ -3029,10 +3142,35 @@ fn extract_options(opts: &CreateTableOptions) -> 
DFResult<Vec<(String, String)>>
         .collect()
 }
 
-fn is_table_not_exist(e: &paimon::Error) -> bool {
+pub(crate) fn is_table_not_exist(e: &paimon::Error) -> bool {
     matches!(e, paimon::Error::TableNotExist { .. })
 }
 
+pub(crate) fn partition_assignment(
+    expr: &SqlExpr,
+    enable_ident_normalization: bool,
+) -> DFResult<(String, &SqlExpr)> {
+    let SqlExpr::BinaryOp {
+        left,
+        op: datafusion::sql::sqlparser::ast::BinaryOperator::Eq,
+        right,
+    } = expr
+    else {
+        return Err(DataFusionError::Plan(format!(
+            "Expected 'column = value' in partition spec, got: {expr}"
+        )));
+    };
+    let SqlExpr::Identifier(identifier) = left.as_ref() else {
+        return Err(DataFusionError::Plan(format!(
+            "Expected column name in partition spec, got: {left}"
+        )));
+    };
+    Ok((
+        normalize_schema_identifier(identifier, enable_ident_normalization),
+        right.as_ref(),
+    ))
+}
+
 /// Parse partition expressions (`col = val, ...`) into partition value maps
 /// suitable for `TableCommit::truncate_partitions`.
 ///
@@ -3050,37 +3188,14 @@ fn parse_partition_values(
     let mut partition = HashMap::new();
     let mut seen_columns = HashSet::new();
     for expr in exprs {
-        let (col_name, val_expr) = match expr {
-            SqlExpr::BinaryOp {
-                left,
-                op: datafusion::sql::sqlparser::ast::BinaryOperator::Eq,
-                right,
-            } => {
-                let col = match left.as_ref() {
-                    SqlExpr::Identifier(ident) => {
-                        normalize_schema_identifier(ident, 
enable_ident_normalization)
-                    }
-                    other => {
-                        return Err(DataFusionError::Plan(format!(
-                            "Expected column name in partition spec, got: 
{other}"
-                        )))
-                    }
-                };
-                (col, right.as_ref())
-            }
-            other => {
-                return Err(DataFusionError::Plan(format!(
-                    "Expected 'column = value' in partition spec, got: {other}"
-                )))
-            }
-        };
+        let (col_name, val_expr) = partition_assignment(expr, 
enable_ident_normalization)?;
 
         if !seen_columns.insert(col_name.clone()) {
             return Err(DataFusionError::Plan(format!(
                 "Duplicate partition column '{col_name}'"
             )));
         }
-        if !partition_keys.iter().any(|k| k == &col_name) {
+        if !partition_keys.contains(&col_name) {
             return Err(DataFusionError::Plan(format!(
                 "Column '{col_name}' is not a partition column"
             )));
@@ -3633,7 +3748,7 @@ fn extract_all_timestamp_as_of(sql: &str) -> 
Vec<TimestampAsOfInfo> {
 }
 
 /// Return an empty DataFrame with a single "result" column containing "OK".
-fn ok_result(ctx: &SessionContext) -> DFResult<DataFrame> {
+pub(crate) fn ok_result(ctx: &SessionContext) -> DFResult<DataFrame> {
     let schema = Arc::new(Schema::new(vec![Field::new(
         "result",
         ArrowDataType::Utf8,
@@ -7753,6 +7868,20 @@ mod tests {
             .unwrap();
     }
 
+    #[tokio::test]
+    async fn test_drop_if_exists_partition_does_not_ignore_missing_table() {
+        let (_tmp, sql_context) = setup_fs_sql_context().await;
+
+        let err = sql_context
+            .sql("ALTER TABLE paimon.test_db.nonexistent DROP IF EXISTS 
PARTITION (pt = 'a')")
+            .await
+            .unwrap_err();
+        assert!(
+            err.to_string().contains("does not exist"),
+            "Expected table-not-exist error, got: {err}"
+        );
+    }
+
     #[tokio::test]
     async fn test_drop_partition_incomplete_spec() {
         let (_tmp, sql_context) = setup_fs_sql_context().await;
diff --git a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs 
b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
new file mode 100644
index 00000000..27005727
--- /dev/null
+++ b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
@@ -0,0 +1,497 @@
+// 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.
+
+mod common;
+
+#[path = "../../../paimon/tests/mock_server.rs"]
+mod mock_server;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use paimon::api::ConfigResponse;
+use paimon::catalog::RESTCatalog;
+use paimon::spec::{BigIntType, BooleanType, DataType, DateType, IntType, 
Schema, VarCharType};
+use paimon::{CatalogOptions, Options};
+use paimon_datafusion::SQLContext;
+use tempfile::TempDir;
+
+use mock_server::{start_mock_server, RESTServer};
+
+const DATABASE: &str = "default";
+const TABLE: &str = "events";
+const TABLE_NAME: &str = "paimon.default.events";
+const WAREHOUSE: &str = "test_warehouse";
+
+async fn setup_rest_table(temp_dir: &TempDir, schema: Schema) -> (RESTServer, 
SQLContext) {
+    let server = start_mock_server(
+        WAREHOUSE.to_string(),
+        temp_dir.path().to_string_lossy().into_owned(),
+        ConfigResponse::new(HashMap::from([(
+            CatalogOptions::PREFIX.to_string(),
+            "mock-test".to_string(),
+        )])),
+        vec![DATABASE.to_string()],
+    )
+    .await;
+    server.add_table_with_schema(
+        DATABASE,
+        TABLE,
+        schema,
+        &format!("file://{}", temp_dir.path().display()),
+    );
+    server.set_table_external(DATABASE, TABLE, false);
+
+    let mut options = Options::new();
+    options.set(CatalogOptions::URI, server.url().unwrap());
+    options.set(CatalogOptions::WAREHOUSE, WAREHOUSE);
+    options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+    options.set(CatalogOptions::TOKEN, "test-token");
+    let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
+    let mut context = SQLContext::new();
+    context.register_catalog("paimon", catalog).await.unwrap();
+    (server, context)
+}
+
+fn format_table_schema(partition_columns: &[(&str, DataType)]) -> Schema {
+    let partition_keys = partition_columns
+        .iter()
+        .map(|(name, _)| (*name).to_string())
+        .collect::<Vec<_>>();
+    partition_columns
+        .iter()
+        .fold(Schema::builder(), |builder, (name, data_type)| {
+            builder.column(*name, data_type.clone())
+        })
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(partition_keys)
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .option("metastore.partitioned-table", "true")
+        .build()
+        .unwrap()
+}
+
+fn varchar() -> DataType {
+    DataType::VarChar(VarCharType::new(255).unwrap())
+}
+
+/// A `(dt, hh)` table with the given partitions added through ADD PARTITION.
+async fn dt_hh_table(partitions: &[(&str, &str)]) -> (TempDir, RESTServer, 
SQLContext) {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let schema = format_table_schema(&[("dt", varchar()), ("hh", varchar())]);
+    let (server, context) = setup_rest_table(&temp_dir, schema).await;
+    for (dt, hh) in partitions {
+        common::exec(
+            &context,
+            &format!(
+                "ALTER TABLE {TABLE_NAME} ADD IF NOT EXISTS PARTITION (dt = 
'{dt}', hh = '{hh}')"
+            ),
+        )
+        .await;
+    }
+    (temp_dir, server, context)
+}
+
+async fn show_partitions(context: &SQLContext, partition_clause: &str) -> 
Vec<String> {
+    let batches = context
+        .sql(&format!("SHOW PARTITIONS {TABLE_NAME}{partition_clause}"))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    batches
+        .iter()
+        .flat_map(|batch| {
+            (0..batch.num_rows()).map(|row| 
common::string_value(batch.column(0), row).to_string())
+        })
+        .collect()
+}
+
+fn spec(values: &[(&str, &str)]) -> HashMap<String, String> {
+    values
+        .iter()
+        .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
+        .collect()
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_partition_commands_update_rest_metadata_and_directories() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let (_server, context) =
+        setup_rest_table(&temp_dir, format_table_schema(&[("dt", 
varchar())])).await;
+
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a') PARTITION 
(dt = 'b')"),
+    )
+    .await;
+    assert_eq!(show_partitions(&context, "").await, ["dt=a", "dt=b"]);
+    assert_eq!(
+        show_partitions(&context, " PARTITION (dt = 'b')").await,
+        ["dt=b"]
+    );
+    assert!(temp_dir.path().join("dt=a").is_dir());
+
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} DROP PARTITION (dt = 'a')"),
+    )
+    .await;
+    assert_eq!(show_partitions(&context, "").await, ["dt=b"]);
+    assert!(!temp_dir.path().join("dt=a").exists());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_partition_literals() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let schema = format_table_schema(&[
+        ("dt", DataType::Date(DateType::new())),
+        ("month", DataType::Int(IntType::new())),
+        ("active", DataType::Boolean(BooleanType::new())),
+        ("label", varchar()),
+    ]);
+    let (_server, context) = setup_rest_table(&temp_dir, schema).await;
+
+    // Literals are read with the column type the way Java reads partition 
strings. Each case adds
+    // a partition, checks its name and directory, and drops it again with the 
same literals.
+    for (literals, name, directory) in [
+        // A typed DATE, a zero-padded INT, a boolean in capitals and a number 
for a string
+        // column. DATE directories hold Unix epoch days.
+        (
+            "dt = DATE '2026-07-22', month = '01', active = 'TRUE', label = 
20260722",
+            "dt=2026-07-22/month=1/active=true/label=20260722",
+            "dt=20656/month=1/active=true/label=20260722",
+        ),
+        // Java's boolean spellings, and an unquoted zero-padded number.
+        (
+            "dt = '2026-07-23', month = 01, active = 'yes', label = 'a'",
+            "dt=2026-07-23/month=1/active=true/label=a",
+            "dt=20657/month=1/active=true/label=a",
+        ),
+        (
+            "dt = '2026-07-24', month = -1, active = '0', label = 'b'",
+            "dt=2026-07-24/month=-1/active=false/label=b",
+            "dt=20658/month=-1/active=false/label=b",
+        ),
+        // NULL is the default partition.
+        (
+            "dt = NULL, month = NULL, active = NULL, label = NULL",
+            "dt=null/month=null/active=null/label=null",
+            "dt=__DEFAULT_PARTITION__/month=__DEFAULT_PARTITION__/\
+             active=__DEFAULT_PARTITION__/label=__DEFAULT_PARTITION__",
+        ),
+    ] {
+        common::exec(
+            &context,
+            &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION ({literals})"),
+        )
+        .await;
+        assert_eq!(show_partitions(&context, "").await, [name], "{literals}");
+        assert!(temp_dir.path().join(directory).is_dir(), "{literals}");
+        common::exec(
+            &context,
+            &format!("ALTER TABLE {TABLE_NAME} DROP PARTITION ({literals})"),
+        )
+        .await;
+        assert!(show_partitions(&context, "").await.is_empty(), "{literals}");
+    }
+
+    for (literals, column) in [
+        (
+            "dt = '2026-07-22', month = '1.5', active = 'true', label = 'a'",
+            "'month'",
+        ),
+        (
+            "dt = '2026-07-22', month = 1, active = 'maybe', label = 'a'",
+            "'active'",
+        ),
+        (
+            "dt = 'yesterday', month = 1, active = 'true', label = 'a'",
+            "'dt'",
+        ),
+    ] {
+        common::assert_sql_error(
+            &context,
+            &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION ({literals})"),
+            column,
+        )
+        .await;
+    }
+}
+
+/// A blank string for a string partition column is written to the default 
partition, so ADD and
+/// DROP refuse it rather than address the NULL partition, as Java does.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_partition_ddl_refuses_blank_string_values() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let schema = format_table_schema(&[("label", varchar())]);
+    let (_server, context) = setup_rest_table(&temp_dir, schema).await;
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (label = NULL)"),
+    )
+    .await;
+    let default_directory = 
temp_dir.path().join("label=__DEFAULT_PARTITION__");
+    assert!(default_directory.is_dir());
+
+    for statement in [
+        "DROP PARTITION (label = '')",
+        "DROP IF EXISTS PARTITION (label = '   ')",
+        "ADD PARTITION (label = '')",
+        "ADD IF NOT EXISTS PARTITION (label = ' ')",
+    ] {
+        common::assert_sql_error(
+            &context,
+            &format!("ALTER TABLE {TABLE_NAME} {statement}"),
+            "empty or whitespace-only string for partition column 'label'",
+        )
+        .await;
+    }
+
+    assert_eq!(show_partitions(&context, "").await, ["label=null"]);
+    assert!(default_directory.is_dir());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_drop_partition_specifications() {
+    const REGISTERED: &[(&str, &str)] =
+        &[("20260722", "10"), ("20260722", "11"), ("20260723", "10")];
+    const ALL: &[&str] = &[
+        "dt=20260722/hh=10",
+        "dt=20260722/hh=11",
+        "dt=20260723/hh=10",
+    ];
+
+    for (operation, error, remaining) in [
+        // A partial specification expands to every registered partition it 
matches.
+        ("DROP PARTITION (dt = '20260722')", None, &["dt=20260723/hh=10"][..]),
+        // The fixed keys need not be a leading prefix.
+        ("DROP PARTITION (hh = '10')", None, &["dt=20260722/hh=11"][..]),
+        // One statement may carry several specifications.
+        (
+            "DROP PARTITION (dt = '20260722', hh = '11'), DROP PARTITION (dt = 
'20260723')",
+            None,
+            &["dt=20260722/hh=10"][..],
+        ),
+        // A complete specification names one partition, so a missing one is 
an error,
+        (
+            "DROP PARTITION (dt = '20260724', hh = '10')",
+            Some("does not exist"),
+            ALL,
+        ),
+        // unless IF EXISTS is given.
+        ("DROP IF EXISTS PARTITION (dt = '20260724', hh = '10')", None, ALL),
+        // A partial specification describes a set that may come out empty.
+        (
+            "DROP PARTITION (dt = '20260724'), DROP PARTITION (dt = 
'20260723')",
+            None,
+            &["dt=20260722/hh=10", "dt=20260722/hh=11"][..],
+        ),
+        // One failing specification leaves the whole statement unapplied.
+        (
+            "DROP PARTITION (dt = '20260722', hh = '10'), DROP PARTITION (dt = 
'20260724', hh = '10')",
+            Some("does not exist"),
+            ALL,
+        ),
+        // Dropping partitions cannot be combined with a schema change.
+        (
+            "DROP PARTITION (dt = '20260722'), ADD COLUMN c INT",
+            Some("must be used alone"),
+            ALL,
+        ),
+    ] {
+        let (temp_dir, _server, context) = dt_hh_table(REGISTERED).await;
+        let sql = format!("ALTER TABLE {TABLE_NAME} {operation}");
+        match error {
+            None => common::exec(&context, &sql).await,
+            Some(expected) => common::assert_sql_error(&context, &sql, 
expected).await,
+        }
+        assert_eq!(show_partitions(&context, "").await, remaining, 
"{operation}");
+        // A dropped partition loses its directory and a kept one keeps it.
+        for (dt, hh) in REGISTERED {
+            let name = format!("dt={dt}/hh={hh}");
+            assert_eq!(
+                temp_dir.path().join(&name).exists(),
+                remaining.contains(&name.as_str()),
+                "{operation}: {name}"
+            );
+        }
+    }
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_drop_partition_matches_values_as_the_catalog_holds_them() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let schema = format_table_schema(&[
+        ("year", varchar()),
+        ("month", DataType::Int(IntType::new())),
+    ]);
+    let (server, context) = setup_rest_table(&temp_dir, schema).await;
+    // Repair keeps directory spellings, so both registrations are legitimate 
and distinct.
+    for directory in ["year=2025/month=01", "year=2026/month=1"] {
+        std::fs::create_dir_all(temp_dir.path().join(directory)).unwrap();
+    }
+    server.set_table_partitions(
+        DATABASE,
+        TABLE,
+        vec![
+            spec(&[("year", "2025"), ("month", "01")]),
+            spec(&[("year", "2026"), ("month", "1")]),
+        ],
+    );
+
+    // SHOW PARTITIONS reads both with the column type.
+    assert_eq!(
+        show_partitions(&context, " PARTITION (month = 1)").await,
+        ["year=2025/month=1", "year=2026/month=1"]
+    );
+
+    // A request is spelled the way ADD PARTITION registers it, so `month = 1` 
is not `month=01`.
+    common::assert_sql_error(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} DROP PARTITION (year = '2025', 
month = 1)"),
+        "does not exist",
+    )
+    .await;
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} DROP IF EXISTS PARTITION (year = 
'2025', month = 1)"),
+    )
+    .await;
+
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} DROP PARTITION (month = 1)"),
+    )
+    .await;
+    assert_eq!(
+        server.table_partition_specs(DATABASE, TABLE),
+        vec![spec(&[("year", "2025"), ("month", "01")])]
+    );
+    assert!(temp_dir.path().join("year=2025/month=01").is_dir());
+    assert!(!temp_dir.path().join("year=2026/month=1").exists());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_drop_partition_looks_up_complete_specifications_by_name() {
+    let (_temp_dir, server, context) =
+        dt_hh_table(&[("20260722", "10"), ("20260722", "11"), ("20260723", 
"10")]).await;
+    let listings = server
+        .table_partition_list_name_patterns(DATABASE, TABLE)
+        .len();
+
+    common::exec(
+        &context,
+        &format!(
+            "ALTER TABLE {TABLE_NAME} \
+             DROP PARTITION (dt = '20260722', hh = '10'), DROP PARTITION (dt = 
'20260723', hh = '10')"
+        ),
+    )
+    .await;
+    assert_eq!(
+        server
+            .table_partition_list_name_patterns(DATABASE, TABLE)
+            .len(),
+        listings,
+        "complete specifications should not read the registry"
+    );
+    assert_eq!(
+        server.table_partition_list_by_names_calls(DATABASE, TABLE),
+        vec![vec![
+            spec(&[("dt", "20260722"), ("hh", "10")]),
+            spec(&[("dt", "20260723"), ("hh", "10")]),
+        ]]
+    );
+
+    // A partial specification needs the registry, and reads it once.
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} DROP PARTITION (dt = '20260722')"),
+    )
+    .await;
+    assert_eq!(
+        server
+            .table_partition_list_name_patterns(DATABASE, TABLE)
+            .len(),
+        listings + 1
+    );
+    assert_eq!(
+        server
+            .table_partition_list_by_names_calls(DATABASE, TABLE)
+            .len(),
+        1
+    );
+    assert!(server.table_partition_specs(DATABASE, TABLE).is_empty());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_drop_partition_leaves_a_custom_location_in_place() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let external_dir = tempfile::tempdir().unwrap();
+    let (server, context) =
+        setup_rest_table(&temp_dir, format_table_schema(&[("dt", 
varchar())])).await;
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a') PARTITION 
(dt = 'b')"),
+    )
+    .await;
+    // Another engine registered dt=b somewhere else; the table directory 
still has its own dt=b.
+    std::fs::write(external_dir.path().join("part-0.parquet"), 
b"data").unwrap();
+    server.set_table_partition_options(
+        DATABASE,
+        TABLE,
+        &spec(&[("dt", "b")]),
+        HashMap::from([(
+            "path".to_string(),
+            format!("file://{}", external_dir.path().display()),
+        )]),
+    );
+
+    // Dropping it unregisters it and deletes nothing, least of all its own 
data.
+    common::exec(
+        &context,
+        &format!("ALTER TABLE {TABLE_NAME} DROP PARTITION (dt = 'b')"),
+    )
+    .await;
+    assert_eq!(
+        server.table_partition_specs(DATABASE, TABLE),
+        vec![spec(&[("dt", "a")])]
+    );
+    assert!(external_dir.path().join("part-0.parquet").exists());
+    assert!(temp_dir.path().join("dt=b").is_dir());
+}
+
+/// `SQLContext::sql` futures have to stay `Send` for callers that box or 
spawn them; this stops
+/// compiling when a stream over borrowed items anywhere below a statement 
takes that away.
+#[allow(dead_code)]
+fn sql_future_is_send<'a>(
+    context: &'a SQLContext,
+    sql: &'a str,
+) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
+    Box::pin(async move {
+        let _ = context.sql(sql).await;
+    })
+}
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 308b78dc..38bd9f13 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -606,6 +606,18 @@ pub trait Catalog: Send + Sync {
         })
     }
 
+    /// Unregister table partition metadata from the catalog, keeping 
directories and data files.
+    /// Missing specs are ignored so callers can safely retry the request.
+    async fn drop_partitions(
+        &self,
+        _identifier: &Identifier,
+        _partition_specs: Vec<HashMap<String, String>>,
+    ) -> Result<()> {
+        Err(Error::Unsupported {
+            message: "Catalog does not support dropping 
partitions".to_string(),
+        })
+    }
+
     /// Return those of the given complete partition specs that are registered.
     ///
     /// Specs are compared with the registered values as they are, without 
normalizing them.
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index b4b9a510..09f9691e 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -455,6 +455,35 @@ impl Catalog for RESTCatalog {
         Ok(())
     }
 
+    async fn drop_partitions(
+        &self,
+        identifier: &Identifier,
+        partition_specs: Vec<HashMap<String, String>>,
+    ) -> Result<()> {
+        if partition_specs.is_empty() {
+            return Ok(());
+        }
+        // The endpoint only unregisters metadata, which is the whole drop 
only for a Format Table
+        // whose catalog owns the partitions; any other table would keep its 
data.
+        let table = self.get_table(identifier).await?;
+        if !table.has_catalog_managed_partitions() {
+            return Err(Error::Unsupported {
+                message: format!(
+                    "Dropping partitions through the REST catalog is supported 
only for Format \
+                     Tables with catalog-managed partitions, and {} is not 
one",
+                    identifier.full_name()
+                ),
+            });
+        }
+        for batch in partition_specs.chunks(PARTITION_BATCH_SIZE) {
+            self.api
+                .drop_partitions(identifier, batch.to_vec(), true)
+                .await
+                .map_err(|error| map_rest_error_for_partition_request(error, 
identifier))?;
+        }
+        Ok(())
+    }
+
     async fn list_partitions_by_names(
         &self,
         identifier: &Identifier,
@@ -656,6 +685,13 @@ fn map_rest_error_for_create_partitions(err: Error, 
identifier: &Identifier) ->
             ),
             source: None,
         },
+        other => map_rest_error_for_partition_request(other, identifier),
+    }
+}
+
+/// Map a REST API error from a partition request other than a create conflict.
+fn map_rest_error_for_partition_request(err: Error, identifier: &Identifier) 
-> Error {
+    match err {
         Error::RestApi {
             source: RestError::BadRequest { message },
         } => Error::DataInvalid {
diff --git a/crates/paimon/src/table/format_partition.rs 
b/crates/paimon/src/table/format_partition.rs
index 90133d3b..deba4111 100644
--- a/crates/paimon/src/table/format_partition.rs
+++ b/crates/paimon/src/table/format_partition.rs
@@ -15,8 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Format Table partition names, paths and values, shared by the scan and the 
catalog
-//! registrations it reads.
+//! Format Table partition names, paths and values, shared by the scan, the 
catalog
+//! registrations it reads and the SQL statements that administer them.
 
 use std::collections::HashMap;
 
@@ -28,14 +28,14 @@ const UNIX_EPOCH_DAYS_FROM_CE: i32 = 719_163;
 
 /// Generates canonical names and physical paths for Format Table partitions.
 #[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct FormatTablePartitionPaths {
+pub struct FormatTablePartitionPaths {
     partition_keys: Vec<String>,
     only_value_in_path: bool,
 }
 
 impl FormatTablePartitionPaths {
     /// Create a helper for the declared partition-key order and physical 
layout.
-    pub(crate) fn new<I, S>(partition_keys: I, only_value_in_path: bool) -> 
Self
+    pub fn new<I, S>(partition_keys: I, only_value_in_path: bool) -> Self
     where
         I: IntoIterator<Item = S>,
         S: Into<String>,
@@ -47,7 +47,7 @@ impl FormatTablePartitionPaths {
     }
 
     /// Return the canonical logical partition name (`key=value/...`).
-    pub(crate) fn partition_name(&self, spec: &HashMap<String, String>) -> 
crate::Result<String> {
+    pub fn partition_name(&self, spec: &HashMap<String, String>) -> 
crate::Result<String> {
         let values = self.ordered_values(spec)?;
         Ok(self
             .partition_keys
@@ -98,7 +98,7 @@ impl FormatTablePartitionPaths {
     }
 
     /// Return the physical partition path relative to the table location.
-    pub(crate) fn relative_path(&self, spec: &HashMap<String, String>) -> 
crate::Result<String> {
+    pub fn relative_path(&self, spec: &HashMap<String, String>) -> 
crate::Result<String> {
         if !self.only_value_in_path {
             return self.partition_name(spec);
         }
@@ -150,7 +150,7 @@ impl FormatTablePartitionPaths {
 }
 
 /// Parse a raw Format Table partition value from a path or catalog 
registration.
-pub(crate) fn parse_format_partition_value(value: &str, data_type: &DataType) 
-> Option<Datum> {
+pub fn parse_format_partition_value(value: &str, data_type: &DataType) -> 
Option<Datum> {
     match data_type {
         DataType::Boolean(_) => parse_partition_bool(value).map(Datum::Bool),
         DataType::TinyInt(_) => value.parse::<i8>().ok().map(Datum::TinyInt),
@@ -165,7 +165,7 @@ pub(crate) fn parse_format_partition_value(value: &str, 
data_type: &DataType) ->
 }
 
 /// Format a typed value for Format Table partition metadata and paths.
-pub(crate) fn format_partition_value(
+pub fn format_partition_value(
     datum: &Datum,
     data_type: &DataType,
     default_partition_name: &str,
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 708aa6b3..bd98ba8e 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -118,6 +118,9 @@ pub use commit_message::CommitMessage;
 pub use consumer_manager::ConsumerManager;
 pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
 pub use data_evolution_writer::{DataEvolutionDeleteWriter, 
DataEvolutionWriter};
+pub use format_partition::{
+    format_partition_value, parse_format_partition_value, 
FormatTablePartitionPaths,
+};
 #[cfg(feature = "fulltext")]
 pub use full_text_search_builder::FullTextSearchBuilder;
 use futures::stream::BoxStream;
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index bd73993b..523541b8 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -1435,6 +1435,26 @@ impl RESTServer {
         inner.partition_list_call_counts.remove(&key);
     }
 
+    /// Return the partition specs registered for a table, in registration 
order.
+    pub fn table_partition_specs(
+        &self,
+        database: &str,
+        table: &str,
+    ) -> Vec<HashMap<String, String>> {
+        self.inner
+            .lock()
+            .unwrap()
+            .partitions
+            .get(&format!("{database}.{table}"))
+            .map(|partitions| {
+                partitions
+                    .iter()
+                    .map(|partition| partition.spec.clone())
+                    .collect()
+            })
+            .unwrap_or_default()
+    }
+
     /// Set whether a stored table is external.
     pub fn set_table_external(&self, database: &str, table: &str, is_external: 
bool) {
         let key = format!("{database}.{table}");
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index b3f6cf53..63729cc5 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -433,6 +433,105 @@ async fn 
test_rest_catalog_create_partitions_maps_missing_table() {
     ));
 }
 
+#[tokio::test]
+async fn test_rest_catalog_drop_partitions_maps_missing_table() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let identifier = Identifier::new("default", "missing");
+
+    let error = ctx
+        .catalog
+        .drop_partitions(
+            &identifier,
+            vec![HashMap::from([(
+                "dt".to_string(),
+                "2026-07-22".to_string(),
+            )])],
+        )
+        .await
+        .unwrap_err();
+
+    assert!(matches!(
+        error,
+        paimon::Error::TableNotExist { full_name } if full_name == 
"default.missing"
+    ));
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_rest_catalog_skips_empty_and_batches_drop_partitions() {
+    let tmp = tempfile::tempdir().unwrap();
+    let ctx = setup_catalog(vec!["default"]).await;
+    let identifier = Identifier::new("default", "managed_table");
+    add_internal_table_with_schema(
+        &ctx.server,
+        "managed_table",
+        format_table_schema(&[]),
+        &format!("file://{}", tmp.path().display()),
+    );
+    let partition_specs = (0..1001)
+        .map(|value| HashMap::from([("dt".to_string(), value.to_string())]))
+        .collect::<Vec<_>>();
+    ctx.server
+        .set_table_partitions("default", "managed_table", 
partition_specs.clone());
+
+    ctx.catalog
+        .drop_partitions(&identifier, Vec::new())
+        .await
+        .unwrap();
+    ctx.catalog
+        .drop_partitions(&identifier, partition_specs.clone())
+        .await
+        .unwrap();
+
+    let calls = ctx.server.drop_partitions_calls();
+    assert_eq!(calls.len(), 2);
+    assert_eq!(calls[0].2.partition_specs, partition_specs[..1000]);
+    assert_eq!(calls[1].2.partition_specs, partition_specs[1000..]);
+    assert!(calls
+        .iter()
+        .all(|(_, _, request)| request.ignore_if_not_exists));
+}
+
+#[tokio::test]
+async fn test_rest_catalog_refuses_to_drop_partitions_it_does_not_manage() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let identifier = Identifier::new("default", "paimon_table");
+    let schema = Schema::builder()
+        .column("dt", DataType::VarChar(VarCharType::new(255).unwrap()))
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(["dt"])
+        .build()
+        .unwrap();
+    ctx.server.add_table_with_schema(
+        "default",
+        "paimon_table",
+        schema,
+        "file:///tmp/test_warehouse/default.db/paimon_table",
+    );
+
+    // Unregistering would report success and leave every data file of a 
Paimon table in place.
+    let error = ctx
+        .catalog
+        .drop_partitions(
+            &identifier,
+            vec![HashMap::from([(
+                "dt".to_string(),
+                "2026-07-22".to_string(),
+            )])],
+        )
+        .await
+        .unwrap_err();
+
+    assert!(
+        matches!(
+            &error,
+            paimon::Error::Unsupported { message } if 
message.contains("default.paimon_table")
+        ),
+        "{error}"
+    );
+    assert!(ctx.server.drop_partitions_calls().is_empty());
+}
+
 #[tokio::test]
 async fn test_rest_catalog_looks_up_partitions_by_names_in_batches() {
     let ctx = setup_catalog(vec!["default"]).await;
diff --git a/docs/src/sql.md b/docs/src/sql.md
index c37ac51c..c2c02e12 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -40,7 +40,7 @@ Mosaic support is always available and currently read-only. 
SQL queries can read
 SQL support has two layers:
 
 - DataFusion provides the parser, query planner, optimizer, execution engine, 
expressions, scalar functions, aggregate functions, and window functions. SQL 
statements that `SQLContext` does not intercept are delegated to DataFusion. 
This includes the DataFusion SQL surface for `SELECT` queries, CTEs (including 
recursive CTEs), subqueries, joins including `LATERAL` joins, SQL lambda 
functions, grouping, `HAVING`, window clauses, `QUALIFY`, set operations, 
`ORDER BY`, `LIMIT`/`OFFSET`, `EX [...]
-- Paimon-specific table management and row-level writes are implemented by 
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, 
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent 
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` / 
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`, 
`TRUNCATE TABLE`, `ALTER TABLE ... DROP PARTITION`, `CALL sys.*`, Paimon time 
travel, and `SET` / `RESET 'paimon.*'`.
+- Paimon-specific table management and row-level writes are implemented by 
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, 
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent 
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` / 
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`, 
`TRUNCATE TABLE`, `ALTER TABLE ... ADD PARTITION`, `ALTER TABLE ... DROP 
PARTITION`, `SHOW PARTITIONS`, `CALL  [...]
 
 Not every DataFusion DDL/DML statement maps to a Paimon table operation. For 
Paimon catalogs, `CREATE EXTERNAL TABLE`, `LOCATION`, `CREATE MATERIALIZED 
VIEW`, and persistent `CREATE TABLE AS SELECT` are rejected or not implemented. 
Persistent `CREATE FUNCTION` is supported only for the REST Catalog SQL scalar 
form documented below. DataFusion `COPY` can export query results to files; it 
does not create or commit Paimon table files.
 
@@ -898,6 +898,76 @@ Multiple partition key-value pairs can be specified:
 ALTER TABLE paimon.my_db.events DROP PARTITION (dt = '2024-01-01', region = 
'us');
 ```
 
+## Format Table Partitions
+
+A `type=format-table` table loaded from a REST Catalog can have its partitions 
managed
+by the catalog instead of discovered from the directory layout. The catalog 
registration
+is then the authoritative partition set: it decides what a scan reads, and a 
directory
+nobody registered is not part of the table.
+
+A table opts in with `'metastore.partitioned-table' = 'true'`. The statements 
below apply
+only to such a table — a partitioned internal Format Table, loaded from a REST 
Catalog,
+with a non-`engine` `format-table.implementation`.
+
+A partition the catalog holds at a custom location (the partition option 
`path`) is not
+read from that location yet: a scan that reaches it fails rather than reading 
the table
+directory in its place.
+
+### SHOW PARTITIONS
+
+```sql
+SHOW PARTITIONS paimon.my_db.events;
+SHOW PARTITIONS paimon.my_db.events PARTITION (dt = '2024-01-01');
+```
+
+Partition names are returned in the escaped `key=value/...` form, sorted. The 
optional
+`PARTITION` clause keeps only the partitions matching the given values; it may 
fix any
+subset of the partition keys.
+
+### ADD PARTITION
+
+```sql
+ALTER TABLE paimon.my_db.events ADD PARTITION (dt = '2024-01-01', region = 
'us');
+ALTER TABLE paimon.my_db.events ADD IF NOT EXISTS PARTITION (dt = '2024-01-01')
+                                                 PARTITION (dt = '2024-01-02');
+```
+
+Every specification must fix all partition keys. A value is read with its 
column type the
+way Paimon reads partition strings, so `month = '01'` and `month = 01` both 
register the INT
+value `1`, and a BOOLEAN column accepts `t`, `true`, `y`, `yes` and `1` or 
their false
+counterparts. The partitions are registered with the catalog first and their 
directories
+are created afterwards, so a failure to create a directory leaves the 
registration in place
+and re-running the statement with `IF NOT EXISTS` completes it. Without `IF 
NOT EXISTS`, an
+already registered partition is an error. A custom `LOCATION` is not 
supported: the
+directory always follows the table's partition layout.
+
+### DROP PARTITION
+
+```sql
+ALTER TABLE paimon.my_db.events DROP PARTITION (dt = '2024-01-01', region = 
'us');
+ALTER TABLE paimon.my_db.events DROP IF EXISTS PARTITION (dt = '2024-01-01');
+```
+
+A specification that fixes only some of the partition keys drops every 
registered
+partition it matches, and the fixed keys need not be a leading prefix — on a
+`(dt, region)` table, `DROP PARTITION (region = 'us')` drops the `us` 
partition of every
+date. A specification that fixes all keys names one partition, so a missing 
one is an
+error unless `IF EXISTS` is given; a partial one describes a set that is 
allowed to come
+out empty.
+
+One statement may carry several specifications, each `DROP PARTITION` in its 
own clause:
+
+```sql
+ALTER TABLE paimon.my_db.events DROP PARTITION (dt = '2024-01-01'),
+                                DROP PARTITION (dt = '2024-01-02');
+```
+
+The catalog registration is removed first, then the directory is deleted by 
the client —
+the catalog never deletes data. If the deletion fails the partition is already 
invisible
+and the directory may survive; repair the file system and remove it there 
rather than
+re-registering it. A partition at a custom location is only unregistered; its 
directory
+is left where it is.
+
 ## Procedures
 
 Use `CALL` to invoke built-in procedures. All procedures are under the `sys` 
namespace.

Reply via email to