Repository: ariga/atlas
Pull request #765 (2022-05-03 14:46:22)

doc/website: terraform announcement blog


Patch:
diff --git a/cmd/atlas/main.go b/cmd/atlas/main.go
index 24643320..aa1636ba 100644
--- a/cmd/atlas/main.go
+++ b/cmd/atlas/main.go
@@ -20,13 +20,10 @@ func main() {
 	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
 	atlascmd.Root.SetOut(os.Stdout)
 	err := atlascmd.Root.ExecuteContext(ctx)
-	// Print error from command
 	if err != nil {
 		atlascmd.Root.PrintErrln("Error:", err)
 	}
-	// Check for update.
 	atlascmd.CheckForUpdate()
-	// Exit code according to command success.
 	if err != nil {
 		os.Exit(1)
 	}
diff --git a/cmd/atlascmd/atlascmd.go b/cmd/atlascmd/atlascmd.go
index 6be07309..1efb2da7 100644
--- a/cmd/atlascmd/atlascmd.go
+++ b/cmd/atlascmd/atlascmd.go
@@ -38,8 +38,8 @@ var (
 		},
 	}
 
-	// envCmd represents the subcommand 'atlas env'.
-	envCmd = &cobra.Command{
+	// EnvCmd represents the subcommand 'atlas env'.
+	EnvCmd = &cobra.Command{
 		Use:   "env",
 		Short: "Print atlas environment variables.",
 		Long: `'atlas env' prints atlas environment information.
@@ -61,13 +61,13 @@ List of supported environment parameters:
 	}
 )
 
-// CheckForUpdate exposes internal update logic to CLI
+// CheckForUpdate exposes internal update logic to CLI.
 func CheckForUpdate() {
-	update.CheckForUpdate(version, Root.PrintErrln)
+	update.Check(version, Root.PrintErrln)
 }
 
 func init() {
-	Root.AddCommand(envCmd)
+	Root.AddCommand(EnvCmd)
 	Root.AddCommand(schemaCmd)
 	Root.AddCommand(versionCmd)
 }
diff --git a/cmd/atlascmd/migrate.go b/cmd/atlascmd/migrate.go
index dae4e6f5..56b1cbca 100644
--- a/cmd/atlascmd/migrate.go
+++ b/cmd/atlascmd/migrate.go
@@ -11,21 +11,21 @@ import (
 	"io/ioutil"
 	"os"
 	"strings"
-	"text/template"
-	"time"
 
 	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlclient"
+	"ariga.io/atlas/sql/sqltool"
 
 	"github.com/spf13/cobra"
 )
 
 const (
+	migrateDiffFlagDevURL  = "dev-url"
 	migrateFlagDir         = "dir"
-	migrateFlagSchema      = "schema"
 	migrateFlagForce       = "force"
-	migrateDiffFlagDevURL  = "dev-url"
+	migrateFlagFormat      = "format"
+	migrateFlagSchema      = "schema"
 	migrateDiffFlagTo      = "to"
 	migrateDiffFlagVerbose = "verbose"
 )
@@ -37,6 +37,7 @@ var (
 		DevURL  string
 		ToURL   string
 		Schemas []string
+		Format  string
 		Force   bool
 		Verbose bool
 	}
@@ -88,6 +89,15 @@ This command should be used whenever a manual change in the migration directory
 		Example: `  atlas migrate hash --force`,
 		RunE:    CmdMigrateHashRun,
 	}
+	// MigrateNewCmd represents the migrate new command.
+	MigrateNewCmd = &cobra.Command{
+		Use:     "new",
+		Short:   "Creates a new empty migration file in the migration directory.",
+		Long:    `'atlas migrate new' creates a new migration according to the configured formatter without any statements in it.`,
+		Example: `  atlas migrate new my-new-migration`,
+		Args:    cobra.MaximumNArgs(1),
+		RunE:    CmdMigrateNewRun,
+	}
 	// MigrateValidateCmd represents the migrate validate command.
 	MigrateValidateCmd = &cobra.Command{
 		Use:   "validate",
@@ -104,11 +114,13 @@ func init() {
 	// Add sub-commands.
 	Root.AddCommand(MigrateCmd)
 	MigrateCmd.AddCommand(MigrateDiffCmd)
-	MigrateCmd.AddCommand(MigrateValidateCmd)
 	MigrateCmd.AddCommand(MigrateHashCmd)
+	MigrateCmd.AddCommand(MigrateNewCmd)
+	MigrateCmd.AddCommand(MigrateValidateCmd)
 	// Global flags.
 	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.DirURL, migrateFlagDir, "", "file://migrations", "select migration directory using DSN format")
 	MigrateCmd.PersistentFlags().StringSliceVarP(&MigrateFlags.Schemas, migrateFlagSchema, "", nil, "set schema names")
+	MigrateCmd.PersistentFlags().StringVarP(&MigrateFlags.Format, migrateFlagFormat, "", formatAtlas, "set migration file format")
 	MigrateCmd.PersistentFlags().BoolVarP(&MigrateFlags.Force, migrateFlagForce, "", false, "force a command to run on a broken migration directory state")
 	MigrateCmd.PersistentFlags().SortFlags = false
 	// Diff flags.
@@ -150,13 +162,12 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	if err != nil {
 		return err
 	}
-	// Only create one file per plan.
-	tf, err := migrate.NewTemplateFormatter(nameTmpl, contentTmpl)
+	f, err := formatter()
 	if err != nil {
 		return err
 	}
 	// Plan the changes and create a new migration file.
-	pl := migrate.NewPlanner(dev.Driver, dir, migrate.WithFormatter(tf))
+	pl := migrate.NewPlanner(dev.Driver, dir, migrate.WithFormatter(f))
 	var name string
 	if len(args) > 0 {
 		name = args[0]
@@ -169,9 +180,25 @@ func CmdMigrateDiffRun(cmd *cobra.Command, args []string) error {
 	return pl.WritePlan(plan)
 }
 
+// CmdMigrateNewRun is the command executed when running the CLI with 'migrate new' args.
+func CmdMigrateNewRun(_ *cobra.Command, args []string) error {
+	dir, err := dir()
+	if err != nil {
+		return err
+	}
+	f, err := formatter()
+	if err != nil {
+		return err
+	}
+	var name string
+	if len(args) > 0 {
+		name = args[0]
+	}
+	return migrate.NewPlanner(nil, dir, migrate.WithFormatter(f)).WritePlan(&migrate.Plan{Name: name})
+}
+
 // CmdMigrateHashRun is the command executed when running the CLI with 'migrate hash' args.
 func CmdMigrateHashRun(*cobra.Command, []string) error {
-	// Open the migration directory.
 	dir, err := dir()
 	if err != nil {
 		return err
@@ -251,20 +278,27 @@ func to(ctx context.Context, client *sqlclient.Client) (migrate.StateReader, err
 	}
 }
 
-var (
-	funcMap = template.FuncMap{
-		"now": func() string { return time.Now().Format("20060102150405") },
-		"sem": func(s string) string {
-			if !strings.HasSuffix(s, ";") {
-				return s + ";"
-			}
-			return s
-		},
-	}
-	nameTmpl = template.Must(template.New("name").Funcs(funcMap).Parse(
-		"{{ now }}{{ with .Name }}_{{ . }}{{ end }}.sql",
-	))
-	contentTmpl = template.Must(template.New("content").Funcs(funcMap).Parse(
-		"{{ range .Changes }}{{ with .Comment }}-- {{ println . }}{{ end }}{{ println (sem .Cmd) }}{{ end }}",
-	))
+const (
+	formatAtlas         = "atlas"
+	formatGolangMigrate = "golang-migrate"
+	formatGoose         = "goose"
+	formatFlyway        = "flyway"
+	formatLiquibase     = "liquibase"
 )
+
+func formatter() (migrate.Formatter, error) {
+	switch MigrateFlags.Format {
+	case formatAtlas:
+		return migrate.DefaultFormatter, nil
+	case formatGolangMigrate:
+		return sqltool.GolangMigrateFormatter, nil
+	case formatGoose:
+		return sqltool.GooseFormatter, nil
+	case formatFlyway:
+		return sqltool.FlywayFormatter, nil
+	case formatLiquibase:
+		return sqltool.LiquibaseFormatter, nil
+	default:
+		return nil, fmt.Errorf("unknown format %q", MigrateFlags.Format)
+	}
+}
diff --git a/cmd/atlascmd/update/update.go b/cmd/atlascmd/update/update.go
index da467db8..89da4546 100644
--- a/cmd/atlascmd/update/update.go
+++ b/cmd/atlascmd/update/update.go
@@ -37,12 +37,12 @@ const (
 	AtlasNoUpdateNotifier = "ATLAS_NO_UPDATE_NOTIFIER"
 )
 
-// CheckForUpdate implements a notification to the user when a later release is available
+// Check implements a notification to the user when a later release is available
 // 1. Check release file ~/.atlas/release.json for latest known release and poll time
 // 2. If last poll was more than 24h, poll GitHub public API https://docs.github.com/en/rest/reference/releases#get-the-latest-release
 // 3. Store the latest release metadata
 // 4. If current build Version, that is not development, is lower than the latest release, notify user
-func CheckForUpdate(version string, logF func(i ...interface{})) {
+func Check(version string, logF func(i ...interface{})) {
 	if !enabled(version) {
 		return
 	}
diff --git a/doc/md/cli/reference.md b/doc/md/cli/reference.md
index cd9bd46d..e0640c88 100644
--- a/doc/md/cli/reference.md
+++ b/doc/md/cli/reference.md
@@ -62,6 +62,7 @@ atlas migrate
 ```
       --dir string       select migration directory using DSN format (default "file://migrations")
       --force            force a command to run on a broken migration directory state
+      --format string    set migration file format (default "atlas")
       --schema strings   set schema names
 
 ```
@@ -115,6 +116,24 @@ This command should be used whenever a manual change in the migration directory
   atlas migrate hash --force
 ```
 
+### atlas migrate new
+
+Creates a new empty migration file in the migration directory.
+
+#### Usage
+```
+atlas migrate new
+```
+
+#### Details
+'atlas migrate new' creates a new migration according to the configured formatter without any statements in it.
+
+#### Example
+
+```
+  atlas migrate new my-new-migration
+```
+
 ### atlas migrate validate
 
 Validates the migration directories checksum.
diff --git a/doc/md/ddl/sql.mdx b/doc/md/ddl/sql.mdx
index 055cd33b..91eb387e 100644
--- a/doc/md/ddl/sql.mdx
+++ b/doc/md/ddl/sql.mdx
@@ -120,6 +120,49 @@ table "products" {
 }
 ```
 
+### Partitions
+
+Table partitioning refers to splitting logical large tables into smaller physical ones. 
+
+:::note
+Atlas currently only supports PostgreSQL. Support for the remaining dialects will be added in future versions.
+:::
+
+```hcl
+table "logs" {
+  schema = schema.public
+  column "date" {
+    type = date
+  }
+  column "text" {
+    type = integer
+  }
+  partition {
+    type = RANGE
+    columns = [column.date]
+  }
+}
+
+table "metrics" {
+  schema = schema.public
+  column "x" {
+    type = integer
+  }
+  column "y" {
+    type = integer
+  }
+  partition {
+    type = RANGE
+    by {
+      column = column.x
+    }
+    by {
+      expr = "floor(y)"
+    }
+  }
+}
+```
+
 ### Table Qualification
 
 In some cases, an Atlas DDL document may contain multiple tables of the same name. This usually happens
diff --git a/doc/md/terraform.md b/doc/md/terraform.md
new file mode 100644
index 00000000..e62a2f88
--- /dev/null
+++ b/doc/md/terraform.md
@@ -0,0 +1,67 @@
+---
+title: Terraform Provider
+id: terraform-provider
+slug: /terraform-provider
+---
+## Introduction
+
+The official [Atlas Terraform provider](https://registry.terraform.io/providers/ariga/atlas/latest) 
+allows you to use Atlas with Terraform to manage your database schemas as part of you Infrastructure-as-Code (Iac)
+workflow . Read about the release announcement [here](https://atlasgo.io/blog/2022-05-03-announcing-terraform-provider).
+* [Documentation](https://registry.terraform.io/providers/ariga/atlas/latest/docs)
+* [GitHub Repository](https://github.com/ariga/terraform-provider-atlas)
+
+## Installation
+Add Atlas to your [required providers](https://www.terraform.io/language/providers/requirements#requiring-providers):
+```hcl
+terraform {
+  required_providers {
+    atlas = {
+      source = "ariga/atlas"
+      version = "~> 0.1.0"
+    }
+  }
+}
+```
+
+## Basic example
+
+Currently, the Atlas Terraform provider uses an [HCL file](/ddl/sql) to describe the
+desired state of the database, and performs migrations according to the state difference 
+between the HCL file and the target database.
+
+To use the Terraform provider, you will need such a file. To quickly get started, either:
+
+1. Use the Atlas CLI to [inspect](cli/getting-started/inspection#inspecting-our-database) an existing database  
+2. Start with an empty, basic schema:
+ 
+```hcl
+schema "example" {
+  charset = "utf8mb4"
+  collate = "utf8mb4_0900_ai_ci"
+}
+```
+
+### Configure Terraform
+
+Use the following configuration to apply the HCL file `schema.hcl` onto a target MySQL 
+database (but you can specify any of the [supported databases](https://github.com/ariga/atlas#supported-databases)):
+
+```hcl title="main.tf"
+provider "atlas" {}
+
+// Load (and normalize) the desired schema from an HCL file.
+data "atlas_schema" "market" {
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+  src = file("${path.module}/schema.hcl")
+}
+
+// Sync the state of the target database with the hcl file.
+resource "atlas_schema" "market" {
+  hcl = data.atlas_schema.market.hcl
+  url = "mysql://root:pass@localhost:3306/market"  
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+}
+```
+
+For the full documentation and examples of the provider visit the [registry page](https://registry.terraform.io/providers/ariga/atlas/latest/docs).
diff --git a/doc/website/blog/2022-05-04-announcing-terraform-provider.md b/doc/website/blog/2022-05-04-announcing-terraform-provider.md
new file mode 100644
index 00000000..7251b35d
--- /dev/null
+++ b/doc/website/blog/2022-05-04-announcing-terraform-provider.md
@@ -0,0 +1,131 @@
+---
+title: "Terraform for databases: Announcing the official Terraform provider for Atlas"
+authors: Hedwigz
+tags: [terraform, announcement]
+image: https://blog.ariga.io/uploads/images/posts/terraform-provider/terraform-provider.png
+---
+
+Today we are glad to announce the release of the official [Atlas Terraform Provider](https://registry.terraform.io/providers/ariga/atlas).
+
+### What is Terraform
+[Terraform](https://terraform.io/) is a popular open-source tool created by [HashiCorp](https://www.hashicorp.com/), used to greatly simplify the task of provisioning and managing resources in the cloud. With Terraform, organizations can describe the desired state of their infrastructure in a simple configuration language and let Terraform plan and apply these changes in an automated way. This way, Terraform allows teams to truly deliver infrastructure-as-code (IaC), which completely change how teams and organizations manage their cloud infrastructure. 
+
+### Infrastructure-as-Code and database management
+Most cloud-native applications are backed by a database. The database is often the most critical part of many software systems, so making changes to its schema (structure and layout of the data inside) is a very risky business. However, schemas must evolve: as functionality changes over time, the backing tables are added, columns are dropped, indexes are created for performance reasons, and more.  
+  
+Therefore it is surprising that there is no established way of integrating the management of schema changes (commonly called schema "migrations") into popular Infrastructure-as-Code workflows. For this reason, many organizations are running migrations from within the application code or using solutions outside the ecosystem of Terraform, meaning that management of the production environment is fragmented and hard to synchronize. Atlas aims to change that.  
+  
+The Atlas Terraform provider allows you to synchronize your database with your desired schema, in a safe and stateful manner. By using Atlas’s core migration engine and embedding it in a Terraform provider, we are enabling teams to manage their database schemas as part of their full IaC workflow. This way, teams can use existing providers (such as AWS or GCP) to provision the database instance and use the Atlas provider to keep the schema in sync. Integrating Atlas with Terraform is especially useful because it couples the state of the infrastructure with the state of the database. It is also extremely neat when using a [dev database](https://atlasgo.io/dev-database), which is a feature that combines infrastructure and DB management to provide safety and correctness.
+
+### Demo
+#### Prerequisites
+Make sure you have installed:
+* [Docker](https://docs.docker.com/get-docker/)
+* [Terraform](https://www.terraform.io/downloads)
+
+Let’s see an example of the provider in action. First, spin a database using docker:
+```bash
+docker run -p 3306:3306 --name iloveatlas -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=market -d mysql:8
+```
+Great! Now we have an instance of MySQL database running.
+
+As an extra measure of safety, we will run another identical database which will serve as a [Dev Database](https://atlasgo.io/cli/dev-database). In short, the dev-db helps to catch errors that can only be detected when applying the schema. It is also useful to format the schema in a correct and predictable way. Read more about it [here](https://atlasgo.io/cli/dev-database).  
+Run a second instance of MySQL on another port, to serve as a dev-db:
+
+```shell
+docker run -p 3307:3306 --name devdb-greatness -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_DATABASE=market -d mysql:8
+```
+
+Next, we need an HCL file describing the desired state of our database. You can use [atlas cli](https://atlasgo.io/cli/getting-started/setting-up) to [inspect](https://atlasgo.io/cli/getting-started/inspection) the state of another database or you can use the following basic schema:
+```hcl title="schema.hcl"
+table "orders" {
+  schema = schema.market
+  column "id" {
+    null           = false
+    type           = int
+    auto_increment = true
+  }
+  column "name" {
+    null = false
+    type = varchar(20)
+  }
+  primary_key {
+    columns = [column.id]
+  }
+}
+
+schema "market" {
+  charset = "utf8mb4"
+  collate = "utf8mb4_0900_ai_ci"
+}
+``` 
+Save the schema file locally in a file named `schema.hcl`.
+
+Now that we have our database schema we can use terraform to apply that schema to our database.
+Create a file named `main.tf` and copy the following snippet:
+```hcl title="main.tf"
+terraform {
+  required_providers {
+    atlas = {
+      version = "~> 0.1.0"
+      source  = "ariga/atlas"
+    }
+  }
+}
+
+provider "atlas" {}
+
+data "atlas_schema" "market" {
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+  src = file("${path.module}/schema.hcl")
+}
+
+resource "atlas_schema" "market" {
+  hcl = data.atlas_schema.market.hcl
+  url = "mysql://root:pass@localhost:3306/market"  
+  dev_db_url = "mysql://root:pass@localhost:3307/market"
+}
+```
+Finally, init terraform:
+```shell
+terraform init
+```
+And apply the schema to the database by executing:
+```shell
+terraform apply --auto-approve
+```
+
+Awesome! Now your database should have a table named `orders`. To verify that we can connect to the database:
+```shell
+$ docker exec -it iloveatlas mysql -ppass --database=market
+
+mysql> show tables;
++------------------+
+| Tables_in_market |
++------------------+
+| orders           |
++------------------+
+1 row in set (0.00 sec)
+
+mysql> show create table orders;
++--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| Table  | Create Table                                                                                                                                                                       |
++--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+| orders | CREATE TABLE `orders` (
+  `id` int NOT NULL AUTO_INCREMENT,
+  `name` varchar(20) NOT NULL,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
++--------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+1 row in set (0.00 sec)
+```
+
+For more examples and documentation visit the official [GitHub repository](https://github.com/ariga/terraform-provider-atlas) or the [provider page](https://registry.terraform.io/providers/ariga/atlas/latest) on Terraform registry.
+
+### What's next
+
+In this post, we presented the [Atlas Terraform Provider](https://registry.terraform.io/providers/ariga/atlas/latest). The provider currently supports the basic, declarative migration workflow that is available in the Atlas engine. In upcoming versions, we will add support for an additional kind of workflow that is supported by the engine and is called [versioned migration authoring](https://entgo.io/blog/2022/03/14/announcing-versioned-migrations/). In addition, more advanced safety checks (such as simulation on database snapshots) and migration strategies are also being worked on.  
+
+While the Terraform provider has just been released, the [core engine](https://github.com/ariga/atlas) that it is driving, is well tested and widely used (especially as the migration engine backing the popular [Ent](https://github.com/ent/ent) framework.) If you, like me, have always wanted to manage your database schema as part of your team's infrastructure-as-code workflow, give the Atlas Terraform provider a try!
+
+Have questions? Feedback? Find our team [on our Discord server](https://discord.gg/zZ6sWVg6NT).
diff --git a/doc/website/blog/authors.yml b/doc/website/blog/authors.yml
new file mode 100644
index 00000000..9b9fd9fe
--- /dev/null
+++ b/doc/website/blog/authors.yml
@@ -0,0 +1,6 @@
+Hedwigz:
+  name: Amit Shani
+  title: Software Engineer at Ariga
+  url: "https://github.com/hedwigz"
+  image_url: "https://avatars.githubusercontent.com/u/8277210?v=4"
+  twitter: itsamitush
\ No newline at end of file
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 475c1c67..852a2a02 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -54,6 +54,10 @@ module.exports = {
             type: 'doc',
             id: 'deployment/deployment',
         },
+        {
+            type: 'doc',
+            id: 'terraform-provider',
+        },
         {
             type: 'category',
             label: 'Go API',
diff --git a/doc/website/src/css/custom.css b/doc/website/src/css/custom.css
index 66af53ed..ecbd4a9d 100644
--- a/doc/website/src/css/custom.css
+++ b/doc/website/src/css/custom.css
@@ -132,4 +132,8 @@ span.token.keyword {
 
 span.token.punctuation {
   color: rgb(191, 199, 213)!important;
+}
+
+div.language-hcl * span.token.property {
+  color: #9CDCFE;
 }
\ No newline at end of file
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index 195f4b48..3ef08f66 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -165,7 +165,7 @@ func Index(spec *sqlspec.Index, parent *schema.Table, partFns ...func(*sqlspec.I
 		return nil, fmt.Errorf(`multiple definitions for index %q, use "columns" or "on"`, spec.Name)
 	case n > 0:
 		for i, c := range spec.Columns {
-			c, err := column(parent, c)
+			c, err := ColumnByRef(parent, c)
 			if err != nil {
 				return nil, err
 			}
@@ -185,7 +185,7 @@ func Index(spec *sqlspec.Index, parent *schema.Table, partFns ...func(*sqlspec.I
 			case p.Expr != "":
 				part.X = &schema.RawExpr{X: p.Expr}
 			case p.Column != nil:
-				c, err := column(parent, p.Column)
+				c, err := ColumnByRef(parent, p.Column)
 				if err != nil {
 					return nil, err
 				}
@@ -223,7 +223,7 @@ func Check(spec *sqlspec.Check) (*schema.Check, error) {
 func PrimaryKey(spec *sqlspec.PrimaryKey, parent *schema.Table) (*schema.Index, error) {
 	parts := make([]*schema.IndexPart, 0, len(spec.Columns))
 	for seqno, c := range spec.Columns {
-		c, err := column(parent, c)
+		c, err := ColumnByRef(parent, c)
 		if err != nil {
 			return nil, nil
 		}
@@ -254,7 +254,7 @@ func linkForeignKeys(tbl *schema.Table, sch *schema.Schema, table *sqlspec.Table
 			return fmt.Errorf("sqlspec: number of referencing and referenced columns do not match for foreign-key %q", fk.Symbol)
 		}
 		for _, ref := range spec.Columns {
-			c, err := column(tbl, ref)
+			c, err := ColumnByRef(tbl, ref)
 			if err != nil {
 				return err
 			}
@@ -264,7 +264,7 @@ func linkForeignKeys(tbl *schema.Table, sch *schema.Schema, table *sqlspec.Table
 			t, c, err := externalRef(ref, sch)
 			if isLocalRef(ref) {
 				t = fk.Table
-				c, err = column(fk.Table, ref)
+				c, err = ColumnByRef(fk.Table, ref)
 			}
 			if err != nil {
 				return err
@@ -346,7 +346,7 @@ func FromTable(t *schema.Table, colFn ColumnSpecFunc, pkFn PrimaryKeySpecFunc, i
 func FromPrimaryKey(s *schema.Index) (*sqlspec.PrimaryKey, error) {
 	c := make([]*schemaspec.Ref, 0, len(s.Parts))
 	for _, v := range s.Parts {
-		c = append(c, colRef(v.C.Name))
+		c = append(c, ColumnRef(v.C.Name))
 	}
 	return &sqlspec.PrimaryKey{
 		Columns: c,
@@ -469,7 +469,7 @@ func FromIndex(idx *schema.Index, partFns ...func(*schema.IndexPart, *sqlspec.In
 		case p.C != nil && p.X != nil:
 			return nil, fmt.Errorf("multiple key part definitions for index %q", idx.Name)
 		case p.C != nil:
-			part.Column = colRef(p.C.Name)
+			part.Column = ColumnRef(p.C.Name)
 		case p.X != nil:
 			x, ok := p.X.(*schema.RawExpr)
 			if !ok {
@@ -491,7 +491,7 @@ func columnsOnly(idx *schema.Index) ([]*schemaspec.Ref, bool) {
 		if p.C == nil || p.Desc {
 			return nil, false
 		}
-		parts[i] = colRef(p.C.Name)
+		parts[i] = ColumnRef(p.C.Name)
 	}
 	return parts, true
 }
@@ -500,11 +500,11 @@ func columnsOnly(idx *schema.Index) ([]*schemaspec.Ref, bool) {
 func FromForeignKey(s *schema.ForeignKey) (*sqlspec.ForeignKey, error) {
 	c := make([]*schemaspec.Ref, 0, len(s.Columns))
 	for _, v := range s.Columns {
-		c = append(c, colRef(v.Name))
+		c = append(c, ColumnRef(v.Name))
 	}
 	r := make([]*schemaspec.Ref, 0, len(s.RefColumns))
 	for _, v := range s.RefColumns {
-		ref := colRef(v.Name)
+		ref := ColumnRef(v.Name)
 		if s.Table != s.RefTable {
 			ref = externalColRef(v.Name, s.RefTable.Name)
 		}
@@ -544,7 +544,8 @@ func SchemaName(ref *schemaspec.Ref) (string, error) {
 	return parts[1], nil
 }
 
-func column(t *schema.Table, ref *schemaspec.Ref) (*schema.Column, error) {
+// ColumnByRef returns a column from the table by its reference.
+func ColumnByRef(t *schema.Table, ref *schemaspec.Ref) (*schema.Column, error) {
 	s := strings.Split(ref.V, "$column.")
 	if len(s) != 2 {
 		return nil, fmt.Errorf("specutil: failed to extract column name from %q", ref)
@@ -561,7 +562,7 @@ func externalRef(ref *schemaspec.Ref, sch *schema.Schema) (*schema.Table, *schem
 	if err != nil {
 		return nil, nil, err
 	}
-	c, err := column(tbl, ref)
+	c, err := ColumnByRef(tbl, ref)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -621,7 +622,8 @@ func isLocalRef(r *schemaspec.Ref) bool {
 	return strings.HasPrefix(r.V, "$column")
 }
 
-func colRef(cName string) *schemaspec.Ref {
+// ColumnRef returns the reference of a column by its name.
+func ColumnRef(cName string) *schemaspec.Ref {
 	return &schemaspec.Ref{V: "$column." + cName}
 }
 
diff --git a/sql/internal/specutil/spec.go b/sql/internal/specutil/spec.go
index 292ecbd5..ea955488 100644
--- a/sql/internal/specutil/spec.go
+++ b/sql/internal/specutil/spec.go
@@ -63,6 +63,14 @@ func VarAttr(k, v string) *schemaspec.Attr {
 	}
 }
 
+// RefAttr is a helper method for constructing *schemaspec.Attr instances that contain a reference.
+func RefAttr(k string, r *schemaspec.Ref) *schemaspec.Attr {
+	return &schemaspec.Attr{
+		K: k,
+		V: r,
+	}
+}
+
 // ListAttr is a helper method for constructing *schemaspec.Attr instances that contain list values.
 func ListAttr(k string, litValues ...string) *schemaspec.Attr {
 	lv := &schemaspec.ListValue{}
diff --git a/sql/internal/sqlx/sqlx.go b/sql/internal/sqlx/sqlx.go
index 67e12e87..a11e906b 100644
--- a/sql/internal/sqlx/sqlx.go
+++ b/sql/internal/sqlx/sqlx.go
@@ -392,29 +392,41 @@ func MayWrap(s string) string {
 	return s
 }
 
-func balanced(s string) bool {
-	var d int
-	for i := 0; i < len(s) && d >= 0; i++ {
+func balanced(expr string) bool {
+	return ExprLastIndex(expr) == len(expr)-1
+}
+
+// ExprLastIndex scans the first expression in the given string until
+// its end and returns its last index.
+func ExprLastIndex(expr string) int {
+	var l, r int
+	for i := 0; i < len(expr); i++ {
 	Top:
-		switch s[i] {
+		switch expr[i] {
 		case '(':
-			d++
+			l++
 		case ')':
-			d--
+			r++
 		// String or identifier.
 		case '\'', '"', '`':
-			for j := i + 1; j < len(s); j++ {
-				switch s[j] {
+			for j := i + 1; j < len(expr); j++ {
+				switch expr[j] {
 				case '\\':
 					j++
-				case s[i]:
+				case expr[i]:
 					i = j
 					break Top
 				}
 			}
 			// Unexpected EOS.
-			return false
+			return -1
+		}
+		// Balanced parens and we reached EOS or a terminator.
+		if l == r && (i == len(expr)-1 || expr[i+1] == ',') {
+			return i
+		} else if r > l {
+			return -1
 		}
 	}
-	return d == 0
+	return -1
 }
diff --git a/sql/migrate/migrate.go b/sql/migrate/migrate.go
index 0b7c5aa1..353a9450 100644
--- a/sql/migrate/migrate.go
+++ b/sql/migrate/migrate.go
@@ -680,7 +680,7 @@ var _ File = (*LocalFile)(nil)
 
 var (
 	// templateFuncs contains the template.FuncMap for the DefaultFormatter.
-	templateFuncs = template.FuncMap{"now": func() string { return time.Now().Format("20060102150405") }}
+	templateFuncs = template.FuncMap{"now": func() string { return time.Now().UTC().Format("20060102150405") }}
 	// DefaultFormatter is a default implementation for Formatter.
 	DefaultFormatter = &TemplateFormatter{
 		templates: []struct{ N, C *template.Template }{
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index 18fc96d6..382e6aa9 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -6,6 +6,7 @@ package postgres
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"reflect"
 	"strconv"
@@ -31,6 +32,9 @@ func (d *diff) TableAttrDiff(from, to *schema.Table) ([]schema.Change, error) {
 	if change := sqlx.CommentDiff(from.Attrs, to.Attrs); change != nil {
 		changes = append(changes, change)
 	}
+	if err := d.partitionChanged(from, to); err != nil {
+		return nil, err
+	}
 	return append(changes, sqlx.CheckDiff(from, to, func(c1, c2 *schema.Check) bool {
 		return sqlx.Has(c1.Attrs, &NoInherit{}) == sqlx.Has(c2.Attrs, &NoInherit{})
 	})...), nil
@@ -99,6 +103,30 @@ func (*diff) generatedChanged(from, to *schema.Column) (bool, error) {
 	}
 }
 
+// partitionChanged checks and returns an error if the partition key of a table was changed.
+func (*diff) partitionChanged(from, to *schema.Table) error {
+	var fromP, toP Partition
+	switch fromHas, toHas := sqlx.Has(from.Attrs, &fromP), sqlx.Has(to.Attrs, &toP); {
+	case fromHas && !toHas:
+		return fmt.Errorf("partition key cannot be dropped from %q (drop and add is required)", from.Name)
+	case !fromHas && toHas:
+		return fmt.Errorf("partition key cannot be added to %q (drop and add is required)", to.Name)
+	case fromHas && toHas:
+		s1, err := formatPartition(fromP)
+		if err != nil {
+			return err
+		}
+		s2, err := formatPartition(toP)
+		if err != nil {
+			return err
+		}
+		if s1 != s2 {
+			return fmt.Errorf("partition key of table %q cannot be changed from %s to %s (drop and add is required)", to.Name, s1, s2)
+		}
+	}
+	return nil
+}
+
 // IsGeneratedIndexName reports if the index name was generated by the database.
 func (d *diff) IsGeneratedIndexName(t *schema.Table, idx *schema.Index) bool {
 	names := make([]string, len(idx.Parts))
@@ -353,6 +381,32 @@ func identity(attrs []schema.Attr) (*Identity, bool) {
 	return i, true
 }
 
+// formatPartition returns the string representation of the
+// partition key according to the PostgreSQL format/grammar.
+func formatPartition(p Partition) (string, error) {
+	b := Build("PARTITION BY")
+	switch t := strings.ToUpper(p.T); t {
+	case PartitionTypeRange, PartitionTypeList, PartitionTypeHash:
+		b.P(t)
+	default:
+		return "", fmt.Errorf("unknown partition type: %q", t)
+	}
+	if len(p.Parts) == 0 {
+		return "", errors.New("missing parts for partition key")
+	}
+	b.Wrap(func(b *sqlx.Builder) {
+		b.MapComma(p.Parts, func(i int, b *sqlx.Builder) {
+			switch k := p.Parts[i]; {
+			case k.C != nil:
+				b.Ident(k.C.Name)
+			case k.X != nil:
+				b.P(sqlx.MayWrap(k.X.(*schema.RawExpr).X))
+			}
+		})
+	})
+	return b.String(), nil
+}
+
 func trimCast(s string) string {
 	i := strings.LastIndex(s, "::")
 	if i == -1 {
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 2e175cc5..be523d5b 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -237,8 +237,15 @@ const (
 	IndexTypeGiST  = "GIST"
 )
 
-// List of "GENERATED" TYPES.
+// List of "GENERATED" types.
 const (
 	GeneratedTypeAlways    = "ALWAYS"
 	GeneratedTypeByDefault = "BY_DEFAULT" // BY DEFAULT.
 )
+
+// List of PARTITION KEY types.
+const (
+	PartitionTypeRange = "RANGE"
+	PartitionTypeList  = "LIST"
+	PartitionTypeHash  = "HASH"
+)
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index 19460ff8..912190f8 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -76,6 +76,9 @@ func (i *inspect) inspectTables(ctx context.Context, r *schema.Realm, opts *sche
 		if err := i.indexes(ctx, s); err != nil {
 			return err
 		}
+		if err := i.partitions(s); err != nil {
+			return err
+		}
 		if err := i.fks(ctx, s); err != nil {
 			return err
 		}
@@ -107,8 +110,8 @@ func (i *inspect) tables(ctx context.Context, realm *schema.Realm, opts *schema.
 	}
 	defer rows.Close()
 	for rows.Next() {
-		var tSchema, name, comment sql.NullString
-		if err := rows.Scan(&tSchema, &name, &comment); err != nil {
+		var tSchema, name, comment, partattrs, partstart, partexprs sql.NullString
+		if err := rows.Scan(&tSchema, &name, &comment, &partattrs, &partstart, &partexprs); err != nil {
 			return fmt.Errorf("scan table information: %w", err)
 		}
 		if !sqlx.ValidString(tSchema) || !sqlx.ValidString(name) {
@@ -121,8 +124,13 @@ func (i *inspect) tables(ctx context.Context, realm *schema.Realm, opts *schema.
 		t := &schema.Table{Name: name.String}
 		s.AddTables(t)
 		if sqlx.ValidString(comment) {
-			t.Attrs = append(t.Attrs, &schema.Comment{
-				Text: comment.String,
+			t.SetComment(comment.String)
+		}
+		if sqlx.ValidString(partattrs) {
+			t.AddAttrs(&Partition{
+				start: partstart.String,
+				attrs: partattrs.String,
+				exprs: partexprs.String,
 			})
 		}
 	}
@@ -405,6 +413,56 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 	return nil
 }
 
+// partitions builds the partition each table in the schema.
+func (i *inspect) partitions(s *schema.Schema) error {
+	for _, t := range s.Tables {
+		var d Partition
+		if !sqlx.Has(t.Attrs, &d) {
+			continue
+		}
+		switch s := strings.ToLower(d.start); s {
+		case "r":
+			d.T = PartitionTypeRange
+		case "l":
+			d.T = PartitionTypeList
+		case "h":
+			d.T = PartitionTypeHash
+		default:
+			return fmt.Errorf("postgres: unexpected partition strategy %q", s)
+		}
+		idxs := strings.Split(strings.TrimSpace(d.attrs), " ")
+		if len(idxs) == 0 {
+			return fmt.Errorf("postgres: no columns/expressions were found in partition key for column %q", t.Name)
+		}
+		for i := range idxs {
+			switch idx, err := strconv.Atoi(idxs[i]); {
+			case err != nil:
+				return fmt.Errorf("postgres: faild parsing partition key index %q", idxs[i])
+			// An expression.
+			case idx == 0:
+				j := sqlx.ExprLastIndex(d.exprs)
+				if j == -1 {
+					return fmt.Errorf("postgres: no expression found in partition key: %q", d.exprs)
+				}
+				d.Parts = append(d.Parts, &PartitionPart{
+					X: &schema.RawExpr{X: d.exprs[:j+1]},
+				})
+				d.exprs = strings.TrimPrefix(d.exprs[j+1:], ", ")
+			// A column at index idx-1.
+			default:
+				if idx > len(t.Columns) {
+					return fmt.Errorf("postgres: unexpected column index %d", idx)
+				}
+				d.Parts = append(d.Parts, &PartitionPart{
+					C: t.Columns[idx-1],
+				})
+			}
+		}
+		schema.ReplaceOrAppend(&t.Attrs, &d)
+	}
+	return nil
+}
+
 // fks queries and appends the foreign keys of the given table.
 func (i *inspect) fks(ctx context.Context, s *schema.Schema) error {
 	rows, err := i.querySchema(ctx, fksQuery, s)
@@ -691,6 +749,28 @@ type (
 		schema.Attr
 		Columns []string
 	}
+
+	// Partition defines the spec of a partitioned table.
+	Partition struct {
+		schema.Attr
+		// T defines the type/strategy of the partition.
+		// Can be one of: RANGE, LIST, HASH.
+		T string
+		// Partition parts. The additional attributes
+		// on each part can be used to control collation.
+		Parts []*PartitionPart
+
+		// Internal info returned from pg_partitioned_table.
+		start, attrs, exprs string
+	}
+
+	// An PartitionPart represents an index part that
+	// can be either an expression or a column.
+	PartitionPart struct {
+		X     schema.Expr
+		C     *schema.Column
+		Attrs []schema.Attr
+	}
 )
 
 const (
@@ -708,10 +788,14 @@ const (
 SELECT
 	t1.table_schema,
 	t1.table_name,
-	pg_catalog.obj_description(t2.oid, 'pg_class') AS COMMENT
+	pg_catalog.obj_description(t2.oid, 'pg_class') AS comment,
+	t3.partattrs AS partition_attrs,
+	t3.partstrat AS partition_strategy,
+	pg_get_expr(t3.partexprs, t3.partrelid) AS partition_exprs
 FROM
 	INFORMATION_SCHEMA.TABLES AS t1
 	JOIN pg_catalog.pg_class AS t2 ON t2.oid = to_regclass(quote_ident(t1.table_schema) || '.' || quote_ident(t1.table_name))::oid
+	LEFT JOIN pg_catalog.pg_partitioned_table AS t3 ON t3.partrelid = t2.oid
 WHERE
 	t1.table_type = 'BASE TABLE'
 	AND t1.table_schema IN (%s)
@@ -722,10 +806,14 @@ ORDER BY
 SELECT
 	t1.table_schema,
 	t1.table_name,
-	pg_catalog.obj_description(t2.oid, 'pg_class') AS COMMENT
+	pg_catalog.obj_description(t2.oid, 'pg_class') AS comment,
+	t3.partattrs AS partition_attrs,
+	t3.partstrat AS partition_strategy,
+	pg_get_expr(t3.partexprs, t3.partrelid) AS partition_exprs
 FROM
 	INFORMATION_SCHEMA.TABLES AS t1
 	JOIN pg_catalog.pg_class AS t2 ON t2.oid = to_regclass(quote_ident(t1.table_schema) || '.' || quote_ident(t1.table_name))::oid
+	LEFT JOIN pg_catalog.pg_partitioned_table AS t3 ON t3.partrelid = t2.oid
 WHERE
 	t1.table_type = 'BASE TABLE'
 	AND t1.table_schema IN (%s)
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 9edaac72..f419b982 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -153,6 +153,13 @@ func (s *state) addTable(ctx context.Context, add *schema.AddTable) error {
 			}
 		}
 	})
+	if p := (Partition{}); sqlx.Has(add.T.Attrs, &p) {
+		s, err := formatPartition(p)
+		if err != nil {
+			errs = append(errs, err.Error())
+		}
+		b.P(s)
+	}
 	if len(errs) > 0 {
 		return fmt.Errorf("create table %q: %s", add.T.Name, strings.Join(errs, ", "))
 	}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index 38f9beb0..e7e574b6 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -106,6 +106,7 @@ var (
 	hclState = schemahcl.New(
 		schemahcl.WithTypes(TypeRegistry.Specs()),
 		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeHash, IndexTypeGIN, IndexTypeGiST),
+		schemahcl.WithScopedEnums("table.partition.type", PartitionTypeRange, PartitionTypeList, PartitionTypeHash),
 		schemahcl.WithScopedEnums("table.column.identity.generated", GeneratedTypeAlways, GeneratedTypeByDefault),
 		schemahcl.WithScopedEnums("table.column.as.type", "STORED"),
 		schemahcl.WithScopedEnums("table.foreign_key.on_update", specutil.ReferenceVars...),
@@ -127,7 +128,105 @@ var (
 // ForeignKeySpecs into ForeignKeys, as the target tables do not necessarily exist in the schema
 // at this point. Instead, the linking is done by the convertSchema function.
 func convertTable(spec *sqlspec.Table, parent *schema.Schema) (*schema.Table, error) {
-	return specutil.Table(spec, parent, convertColumn, specutil.PrimaryKey, convertIndex, specutil.Check)
+	t, err := specutil.Table(spec, parent, convertColumn, specutil.PrimaryKey, convertIndex, specutil.Check)
+	if err != nil {
+		return nil, err
+	}
+	if err := convertPartition(spec.Extra, t); err != nil {
+		return nil, err
+	}
+	return t, nil
+}
+
+// convertPartition converts and appends the partition block into the table attributes if exists.
+func convertPartition(spec schemaspec.Resource, table *schema.Table) error {
+	r, ok := spec.Resource("partition")
+	if !ok {
+		return nil
+	}
+	var p struct {
+		Type    string            `spec:"type"`
+		Columns []*schemaspec.Ref `spec:"columns"`
+		Parts   []*struct {
+			Expr   string          `spec:"expr"`
+			Column *schemaspec.Ref `spec:"column"`
+		} `spec:"by"`
+	}
+	if err := r.As(&p); err != nil {
+		return fmt.Errorf("parsing %s.partition: %w", table.Name, err)
+	}
+	if p.Type == "" {
+		return fmt.Errorf("missing attribute %s.partition.type", table.Name)
+	}
+	key := &Partition{T: p.Type}
+	switch n, m := len(p.Columns), len(p.Parts); {
+	case n == 0 && m == 0:
+		return fmt.Errorf("missing columns or expressions for %s.partition", table.Name)
+	case n > 0 && m > 0:
+		return fmt.Errorf(`multiple definitions for %s.partition, use "columns" or "by"`, table.Name)
+	case n > 0:
+		for _, r := range p.Columns {
+			c, err := specutil.ColumnByRef(table, r)
+			if err != nil {
+				return err
+			}
+			key.Parts = append(key.Parts, &PartitionPart{C: c})
+		}
+	case m > 0:
+		for i, p := range p.Parts {
+			switch {
+			case p.Column == nil && p.Expr == "":
+				return fmt.Errorf("missing column or expression for %s.partition.by at position %d", table.Name, i)
+			case p.Column != nil && p.Expr != "":
+				return fmt.Errorf("multiple definitions for  %s.partition.by at position %d", table.Name, i)
+			case p.Column != nil:
+				c, err := specutil.ColumnByRef(table, p.Column)
+				if err != nil {
+					return err
+				}
+				key.Parts = append(key.Parts, &PartitionPart{C: c})
+			case p.Expr != "":
+				key.Parts = append(key.Parts, &PartitionPart{X: &schema.RawExpr{X: p.Expr}})
+			}
+		}
+	}
+	table.AddAttrs(key)
+	return nil
+}
+
+// fromPartition returns the resource spec for representing the partition block.
+func fromPartition(p Partition) *schemaspec.Resource {
+	key := &schemaspec.Resource{
+		Type: "partition",
+		Attrs: []*schemaspec.Attr{
+			specutil.VarAttr("type", strings.ToUpper(specutil.Var(p.T))),
+		},
+	}
+	columns, ok := func() (*schemaspec.ListValue, bool) {
+		parts := make([]schemaspec.Value, 0, len(p.Parts))
+		for _, p := range p.Parts {
+			if p.C == nil {
+				return nil, false
+			}
+			parts = append(parts, specutil.ColumnRef(p.C.Name))
+		}
+		return &schemaspec.ListValue{V: parts}, true
+	}()
+	if ok {
+		key.Attrs = append(key.Attrs, &schemaspec.Attr{K: "columns", V: columns})
+		return key
+	}
+	for _, p := range p.Parts {
+		part := &schemaspec.Resource{Type: "by"}
+		switch {
+		case p.C != nil:
+			part.Attrs = append(part.Attrs, specutil.RefAttr("column", specutil.ColumnRef(p.C.Name)))
+		case p.X != nil:
+			part.Attrs = append(part.Attrs, specutil.StrAttr("expr", p.X.(*schema.RawExpr).X))
+		}
+		key.Children = append(key.Children, part)
+	}
+	return key
 }
 
 // convertColumn converts a sqlspec.Column into a schema.Column.
@@ -316,15 +415,22 @@ func schemaSpec(schem *schema.Schema) (*doc, error) {
 }
 
 // tableSpec converts from a concrete Postgres sqlspec.Table to a schema.Table.
-func tableSpec(tab *schema.Table) (*sqlspec.Table, error) {
-	return specutil.FromTable(
-		tab,
+func tableSpec(table *schema.Table) (*sqlspec.Table, error) {
+	spec, err := specutil.FromTable(
+		table,
 		columnSpec,
 		specutil.FromPrimaryKey,
 		indexSpec,
 		specutil.FromForeignKey,
 		specutil.FromCheck,
 	)
+	if err != nil {
+		return nil, err
+	}
+	if p := (Partition{}); sqlx.Has(table.Attrs, &p) {
+		spec.Extra.Children = append(spec.Extra.Children, fromPartition(p))
+	}
+	return spec, nil
 }
 
 func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
diff --git a/sql/sqltool/tool.go b/sql/sqltool/tool.go
index 91806d43..5919ac12 100644
--- a/sql/sqltool/tool.go
+++ b/sql/sqltool/tool.go
@@ -51,7 +51,7 @@ var (
 	funcs = template.FuncMap{
 		"inc": func(x int) int { return x + 1 },
 		// now format the current time in a lexicographically ascending order while maintaining human readability.
-		"now": func() string { return time.Now().Format("20060102150405") },
+		"now": func() string { return time.Now().UTC().Format("20060102150405") },
 		"rev": reverse,
 	}
 )


Test patch:
diff --git a/cmd/atlascmd/migrate_test.go b/cmd/atlascmd/migrate_test.go
index 69f18018..7dba8efe 100644
--- a/cmd/atlascmd/migrate_test.go
+++ b/cmd/atlascmd/migrate_test.go
@@ -56,7 +56,7 @@ func TestMigrate_Diff(t *testing.T) {
 	)
 	require.Zero(t, s)
 	require.NoError(t, err)
-	require.FileExists(t, filepath.Join(p, fmt.Sprintf("%s_name.sql", time.Now().Format("20060102150405"))))
+	require.FileExists(t, filepath.Join(p, fmt.Sprintf("%s_name.sql", time.Now().UTC().Format("20060102150405"))))
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
 
 	// A lock will prevent diffing.
@@ -82,6 +82,70 @@ func TestMigrate_Diff(t *testing.T) {
 	require.EqualError(t, err, lockErr)
 }
 
+func TestMigrate_New(t *testing.T) {
+	var (
+		p = t.TempDir()
+		v = time.Now().UTC().Format("20060102150405")
+	)
+
+	s, err := runCmd(Root, "migrate", "new", "--dir", "file://"+p)
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, v+".sql"))
+	require.FileExists(t, filepath.Join(p, "atlas.sum"))
+	require.Equal(t, 2, countFiles(t, p))
+
+	s, err = runCmd(Root, "migrate", "new", "my-migration-file", "--dir", "file://"+p)
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, v+"_my-migration-file.sql"))
+	require.FileExists(t, filepath.Join(p, "atlas.sum"))
+	require.Equal(t, 3, countFiles(t, p))
+
+	s, err = runCmd(Root, "migrate", "new", "golang-migrate", "--dir", "file://"+p, "--format", formatGolangMigrate)
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, v+"_golang-migrate.up.sql"))
+	require.FileExists(t, filepath.Join(p, v+"_golang-migrate.down.sql"))
+	require.Equal(t, 5, countFiles(t, p))
+
+	s, err = runCmd(Root, "migrate", "new", "goose", "--dir", "file://"+p, "--format", formatGoose)
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, v+"_goose.sql"))
+	require.Equal(t, 6, countFiles(t, p))
+
+	s, err = runCmd(Root, "migrate", "new", "flyway", "--dir", "file://"+p, "--format", formatFlyway)
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, fmt.Sprintf("V%s__%s.sql", v, formatFlyway)))
+	require.FileExists(t, filepath.Join(p, fmt.Sprintf("U%s__%s.sql", v, formatFlyway)))
+	require.Equal(t, 8, countFiles(t, p))
+
+	s, err = runCmd(Root, "migrate", "new", "liquibase", "--dir", "file://"+p, "--format", formatLiquibase)
+	require.Zero(t, s)
+	require.NoError(t, err)
+	require.FileExists(t, filepath.Join(p, v+"_liquibase.sql"))
+	require.Equal(t, 9, countFiles(t, p))
+}
+
+func TestMigrate_NewError(t *testing.T) {
+	if os.Getenv("DO_NEW") == "1" {
+		runCmd(Root, "migrate", "new", "--dir", "file://testdata")
+		return
+	}
+	f := filepath.Join("testdata", "new.sql")
+	require.NoError(t, os.WriteFile(f, []byte("contents"), 0600))
+	defer os.Remove(f)
+	cmd := exec.Command(os.Args[0], "-test.run=TestMigrate_NewError") //nolint:gosec
+	cmd.Env = append(os.Environ(), "DO_NEW=1")
+	err := cmd.Run()
+	if err, ok := err.(*exec.ExitError); ok && !err.Success() {
+		return
+	}
+	t.Fatalf("process ran with err %v, want exit status 1", err)
+}
+
 func TestMigrate_Validate(t *testing.T) {
 	s, err := runCmd(Root, "migrate", "validate", "--dir", "file://testdata")
 	require.Zero(t, s)
@@ -102,7 +166,7 @@ func TestMigrate_ValidateError(t *testing.T) {
 	if err, ok := err.(*exec.ExitError); ok && !err.Success() {
 		return
 	}
-	t.Fatalf("process ran with err %v, want exist status 1", err)
+	t.Fatalf("process ran with err %v, want exit status 1", err)
 }
 
 func TestMigrate_Hash(t *testing.T) {
@@ -143,7 +207,7 @@ func TestMigrate_HashError(t *testing.T) {
 	if err, ok := err.(*exec.ExitError); ok && !err.Success() {
 		return
 	}
-	t.Fatalf("process ran with err %v, want exist status 1", err)
+	t.Fatalf("process ran with err %v, want exit status 1", err)
 }
 
 const hcl = `
@@ -254,3 +318,9 @@ const lockErr = "lockErr"
 func (d *sqliteLockerDriver) Lock(context.Context, string, time.Duration) (schema.UnlockFunc, error) {
 	return func() error { return nil }, errors.New(lockErr)
 }
+
+func countFiles(t *testing.T, p string) int {
+	files, err := os.ReadDir(p)
+	require.NoError(t, err)
+	return len(files)
+}
diff --git a/internal/integration/testdata/postgres/table-partition.txt b/internal/integration/testdata/postgres/table-partition.txt
new file mode 100644
index 00000000..d1b219ab
--- /dev/null
+++ b/internal/integration/testdata/postgres/table-partition.txt
@@ -0,0 +1,290 @@
+apply 1.hcl
+cmpshow logs 1.sql
+
+# Changing partitioned table is not allowed.
+! apply 2.hcl 'partition key of table "logs" cannot be changed from PARTITION BY LIST ("value") to PARTITION BY RANGE ("a", (b * (a % 2))) (drop and add is required)'
+
+# Drop all tables.
+apply drop.hcl
+
+# Recreate partitioned table.
+apply 2.hcl
+cmpshow logs 2.sql
+
+# Drop all tables.
+apply drop.hcl
+
+apply 3.hcl
+cmpshow measurement 3.sql
+cmphcl 3.inspect.hcl
+
+# Drop all tables.
+apply drop.hcl
+
+apply 4.hcl
+cmpshow metrics 4.sql
+cmphcl 4.inspect.hcl
+
+-- 1.hcl --
+schema "$db" {}
+
+table "logs" {
+  schema = schema.$db
+  column "value" {
+    null = false
+    type = integer
+  }
+  partition {
+    type = LIST
+    columns = [column.value]
+  }
+}
+
+-- postgres10/1.sql --
+        Table "script_table_partition.logs"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ value  | integer |           | not null |
+Partition key: LIST (value)
+
+-- postgres11/1.sql --
+        Table "script_table_partition.logs"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ value  | integer |           | not null |
+Partition key: LIST (value)
+Number of partitions: 0
+
+-- 1.sql --
+  Partitioned table "script_table_partition.logs"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ value  | integer |           | not null |
+Partition key: LIST (value)
+Number of partitions: 0
+
+-- 2.hcl --
+schema "$db" {}
+
+table "logs" {
+  schema = schema.$db
+  column "a" {
+    null = false
+    type = integer
+  }
+  column "b" {
+    null = false
+    type = integer
+  }
+  partition {
+    type = RANGE
+    by {
+      column = column.a
+    }
+    by {
+      expr = "b * (a % 2)"
+    }
+  }
+}
+
+-- postgres10/2.sql --
+        Table "script_table_partition.logs"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ a      | integer |           | not null |
+ b      | integer |           | not null |
+Partition key: RANGE (a, ((b * (a % 2))))
+
+-- postgres11/2.sql --
+        Table "script_table_partition.logs"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ a      | integer |           | not null |
+ b      | integer |           | not null |
+Partition key: RANGE (a, ((b * (a % 2))))
+Number of partitions: 0
+
+-- 2.sql --
+  Partitioned table "script_table_partition.logs"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ a      | integer |           | not null |
+ b      | integer |           | not null |
+Partition key: RANGE (a, ((b * (a % 2))))
+Number of partitions: 0
+
+-- 3.hcl --
+schema "$db" {}
+
+# The partitioned table from the PostgreSQL doc.
+table "measurement" {
+  schema = schema.$db
+  column "city_id" {
+    null = false
+    type = integer
+  }
+  column "logdate" {
+    null = false
+    type = date
+  }
+  column "peaktemp" {
+    null = true
+    type = int
+  }
+  column "unitsales" {
+    null = true
+    type = int
+  }
+  partition {
+    type = RANGE
+    columns = [column.logdate]
+  }
+}
+
+-- postgres10/3.sql --
+      Table "script_table_partition.measurement"
+  Column   |  Type   | Collation | Nullable | Default
+-----------+---------+-----------+----------+---------
+ city_id   | integer |           | not null |
+ logdate   | date    |           | not null |
+ peaktemp  | integer |           |          |
+ unitsales | integer |           |          |
+Partition key: RANGE (logdate)
+
+-- postgres11/3.sql --
+      Table "script_table_partition.measurement"
+  Column   |  Type   | Collation | Nullable | Default
+-----------+---------+-----------+----------+---------
+ city_id   | integer |           | not null |
+ logdate   | date    |           | not null |
+ peaktemp  | integer |           |          |
+ unitsales | integer |           |          |
+Partition key: RANGE (logdate)
+Number of partitions: 0
+
+-- 3.sql --
+Partitioned table "script_table_partition.measurement"
+  Column   |  Type   | Collation | Nullable | Default
+-----------+---------+-----------+----------+---------
+ city_id   | integer |           | not null |
+ logdate   | date    |           | not null |
+ peaktemp  | integer |           |          |
+ unitsales | integer |           |          |
+Partition key: RANGE (logdate)
+Number of partitions: 0
+
+-- 3.inspect.hcl --
+table "measurement" {
+  schema = schema.script_table_partition
+  column "city_id" {
+    null = false
+    type = integer
+  }
+  column "logdate" {
+    null = false
+    type = date
+  }
+  column "peaktemp" {
+    null = true
+    type = integer
+  }
+  column "unitsales" {
+    null = true
+    type = integer
+  }
+  partition {
+    type    = RANGE
+    columns = [column.logdate]
+  }
+}
+schema "script_table_partition" {
+}
+
+-- 4.hcl --
+schema "$db" {}
+
+table "metrics" {
+  schema = schema.$db
+  column "x" {
+    null = false
+    type = integer
+  }
+  column "y" {
+    null = false
+    type = integer
+  }
+  partition {
+    type = RANGE
+    by {
+        column = column.x
+    }
+    by {
+        expr = "floor(x)"
+    }
+    by {
+        expr = "y * 2"
+    }
+    by {
+        expr = "floor(y)"
+    }
+  }
+}
+
+-- postgres10/4.sql --
+      Table "script_table_partition.metrics"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ x      | integer |           | not null |
+ y      | integer |           | not null |
+Partition key: RANGE (x, floor((x)::double precision), ((y * 2)), floor((y)::double precision))
+
+-- postgres11/4.sql --
+      Table "script_table_partition.metrics"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ x      | integer |           | not null |
+ y      | integer |           | not null |
+Partition key: RANGE (x, floor((x)::double precision), ((y * 2)), floor((y)::double precision))
+Number of partitions: 0
+
+-- 4.sql --
+Partitioned table "script_table_partition.metrics"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ x      | integer |           | not null |
+ y      | integer |           | not null |
+Partition key: RANGE (x, floor((x)::double precision), ((y * 2)), floor((y)::double precision))
+Number of partitions: 0
+
+-- 4.inspect.hcl --
+table "metrics" {
+  schema = schema.script_table_partition
+  column "x" {
+    null = false
+    type = integer
+  }
+  column "y" {
+    null = false
+    type = integer
+  }
+  partition {
+    type = RANGE
+    by {
+      column = column.x
+    }
+    by {
+      expr = "floor((x)::double precision)"
+    }
+    by {
+      expr = "(y * 2)"
+    }
+    by {
+      expr = "floor((y)::double precision)"
+    }
+  }
+}
+schema "script_table_partition" {
+}
+
+-- drop.hcl --
+schema "$db" {}
\ No newline at end of file
diff --git a/sql/internal/sqlx/sqlx_test.go b/sql/internal/sqlx/sqlx_test.go
index b839d112..fe27f04f 100644
--- a/sql/internal/sqlx/sqlx_test.go
+++ b/sql/internal/sqlx/sqlx_test.go
@@ -80,9 +80,9 @@ func TestMayWrap(t *testing.T) {
 		{"b || 'x'", true},
 		{"a+1", true},
 		{"substr(x, 2)", true},
-		{"(json_extract(x, '$.a'), json_extract(x, '$.b'))", false},
-		{"(substr(a, 2) COLLATE NOCASE, b)", false},
-		{"(a,b+random())", false},
+		{"(json_extract(x, '$.a'))", false},
+		{"(substr(a, 2) COLLATE NOCASE)", false},
+		{"(b+random())", false},
 	}
 	for i, tt := range tests {
 		t.Run(strconv.Itoa(i), func(t *testing.T) {
@@ -95,3 +95,30 @@ func TestMayWrap(t *testing.T) {
 		})
 	}
 }
+
+func TestExprLastIndex(t *testing.T) {
+	tests := []struct {
+		input   string
+		wantIdx int
+	}{
+		{"", -1},
+		{"()", 1},
+		{"'('", 2},
+		{"('(')", 4},
+		{"('text')", 7},
+		{"floor(x), y", 7},
+		{"f(floor(x), y)", 13},
+		{"f(floor(x), y, (z))", 18},
+		{"f(x, (x*2)), y, (z)", 10},
+		{"(a || ' ' || b)", 14},
+		{"(a || ', ' || b)", 15},
+		{"a || ', ' || b, x", 13},
+		{"(a || ', ' || b), x", 15},
+	}
+	for i, tt := range tests {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			idx := ExprLastIndex(tt.input)
+			require.Equal(t, tt.wantIdx, idx)
+		})
+	}
+}
diff --git a/sql/migrate/migrate_test.go b/sql/migrate/migrate_test.go
index 6a39a6b8..7b3f4b58 100644
--- a/sql/migrate/migrate_test.go
+++ b/sql/migrate/migrate_test.go
@@ -39,7 +39,7 @@ func TestPlanner_WritePlan(t *testing.T) {
 	pl := migrate.NewPlanner(nil, d, migrate.DisableChecksum())
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
-	v := time.Now().Format("20060102150405")
+	v := time.Now().UTC().Format("20060102150405")
 	require.Equal(t, countFiles(t, d), 1)
 	requireFileEqual(t, d, v+"_add_t1_and_t2.sql", "CREATE TABLE t1(c int);\nCREATE TABLE t2(c int);\n")
 
@@ -97,7 +97,7 @@ func TestHashSum(t *testing.T) {
 	pl := migrate.NewPlanner(nil, d)
 	require.NotNil(t, pl)
 	require.NoError(t, pl.WritePlan(plan))
-	v := time.Now().Format("20060102150405")
+	v := time.Now().UTC().Format("20060102150405")
 	require.Equal(t, 2, countFiles(t, d))
 	requireFileEqual(t, d, v+"_plan.sql", "cmd;\n")
 	require.FileExists(t, filepath.Join(p, "atlas.sum"))
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index c7909a3b..17a27e79 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -69,6 +69,54 @@ func TestDiff_TableDiff(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "drop partition key",
+			from: schema.NewTable("logs").
+				AddAttrs(&Partition{
+					T:     PartitionTypeRange,
+					Parts: []*PartitionPart{{C: schema.NewColumn("c")}},
+				}),
+			to:      schema.NewTable("logs"),
+			wantErr: true,
+		},
+		{
+			name: "add partition key",
+			from: schema.NewTable("logs"),
+			to: schema.NewTable("logs").
+				AddAttrs(&Partition{
+					T:     PartitionTypeRange,
+					Parts: []*PartitionPart{{C: schema.NewColumn("c")}},
+				}),
+			wantErr: true,
+		},
+		{
+			name: "change partition key column",
+			from: schema.NewTable("logs").
+				AddAttrs(&Partition{
+					T:     PartitionTypeRange,
+					Parts: []*PartitionPart{{C: schema.NewColumn("c")}},
+				}),
+			to: schema.NewTable("logs").
+				AddAttrs(&Partition{
+					T:     PartitionTypeRange,
+					Parts: []*PartitionPart{{C: schema.NewColumn("d")}},
+				}),
+			wantErr: true,
+		},
+		{
+			name: "change partition key type",
+			from: schema.NewTable("logs").
+				AddAttrs(&Partition{
+					T:     PartitionTypeRange,
+					Parts: []*PartitionPart{{C: schema.NewColumn("c")}},
+				}),
+			to: schema.NewTable("logs").
+				AddAttrs(&Partition{
+					T:     PartitionTypeHash,
+					Parts: []*PartitionPart{{C: schema.NewColumn("c")}},
+				}),
+			wantErr: true,
+		},
 		{
 			name: "add check",
 			from: &schema.Table{Name: "t1", Schema: &schema.Schema{Name: "public"}},
@@ -309,7 +357,7 @@ func TestDiff_TableDiff(t *testing.T) {
 		require.NoError(t, err)
 		t.Run(tt.name, func(t *testing.T) {
 			changes, err := drv.TableDiff(tt.from, tt.to)
-			require.Equal(t, tt.wantErr, err != nil)
+			require.Equalf(t, tt.wantErr, err != nil, "got: %v", err)
 			require.EqualValues(t, tt.wantChanges, changes)
 		})
 	}
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index b6769424..371b106b 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -300,6 +300,74 @@ users        | users_check1       | (((c2 + c1) + c3) > 10) | c3          | {2,1
 	}
 }
 
+func TestDriver_InspectPartitionedTable(t *testing.T) {
+	db, m, err := sqlmock.New()
+	require.NoError(t, err)
+	mk := mock{m}
+	mk.version("130000")
+	drv, err := Open(db)
+	require.NoError(t, err)
+	mk.ExpectQuery(sqltest.Escape(fmt.Sprintf(schemasQueryArgs, "= CURRENT_SCHEMA()"))).
+		WillReturnRows(sqltest.Rows(`
+   schema_name
+--------------------
+public
+`))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(tablesQuery, "$1"))).
+		WithArgs("public").
+		WillReturnRows(sqltest.Rows(`
+ table_schema | table_name  | comment | partition_attrs | partition_strategy |                  partition_exprs                   
+--------------+-------------+---------+-----------------+--------------------+----------------------------------------------------
+ public       | logs1       |         |                 |                    | 
+ public       | logs2       |         | 1               | r                  | 
+ public       | logs3       |         | 2 0 0           | l                  | (a + b), (a + (b * 2))
+
+`))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(columnsQuery, "$2, $3, $4"))).
+		WithArgs("public", "logs1", "logs2", "logs3").
+		WillReturnRows(sqltest.Rows(`
+table_name |column_name | data_type | is_nullable | column_default | character_maximum_length | numeric_precision | datetime_precision | numeric_scale | character_set_name | collation_name | udt_name | is_identity | identity_start | identity_increment | identity_generation | generation_expression | comment | typtype | oid
+-----------+------------+-----------+-------------+----------------+--------------------------+-------------------+--------------------+---------------+--------------------+----------------+----------+-------------+----------------+--------------------+---------------------+-----------------------+---------+---------+-----
+logs1      | c1         | integer   | NO          |                |                          |                32 |                    |             0 |                    |                | int4     | NO          |                |                    |                     |                       |         | b       |  23
+logs2      | c2         | integer   | NO          |                |                          |                32 |                    |             0 |                    |                | int4     | NO          |                |                    |                     |                       |         | b       |  23
+logs2      | c3         | integer   | NO          |                |                          |                32 |                    |             0 |                    |                | int4     | NO          |                |                    |                     |                       |         | b       |  23
+logs3      | c4         | integer   | NO          |                |                          |                32 |                    |             0 |                    |                | int4     | NO          |                |                    |                     |                       |         | b       |  23
+logs3      | c5         | integer   | NO          |                |                          |                32 |                    |             0 |                    |                | int4     | NO          |                |                    |                     |                       |         | b       |  23
+`))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(indexesQuery, "$2, $3, $4"))).
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression"}))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(fksQuery, "$2, $3, $4"))).
+		WillReturnRows(sqlmock.NewRows([]string{"constraint_name", "table_name", "column_name", "referenced_table_name", "referenced_column_name", "referenced_table_schema", "update_rule", "delete_rule"}))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(checksQuery, "$2, $3, $4"))).
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "constraint_name", "expression", "column_name", "column_indexes"}))
+	s, err := drv.InspectSchema(context.Background(), "", &schema.InspectOptions{})
+	require.NoError(t, err)
+
+	t1, ok := s.Table("logs1")
+	require.True(t, ok)
+	require.Empty(t, t1.Attrs)
+
+	t2, ok := s.Table("logs2")
+	require.True(t, ok)
+	require.Len(t, t2.Attrs, 1)
+	key := t2.Attrs[0].(*Partition)
+	require.Equal(t, PartitionTypeRange, key.T)
+	require.Equal(t, []*PartitionPart{
+		{C: &schema.Column{Name: "c2", Type: &schema.ColumnType{Raw: "integer", Type: &schema.IntegerType{T: "integer"}}}},
+	}, key.Parts)
+
+	t3, ok := s.Table("logs3")
+	require.True(t, ok)
+	require.Len(t, t3.Attrs, 1)
+	key = t3.Attrs[0].(*Partition)
+	require.Equal(t, PartitionTypeList, key.T)
+	require.Equal(t, []*PartitionPart{
+		{C: &schema.Column{Name: "c5", Type: &schema.ColumnType{Raw: "integer", Type: &schema.IntegerType{T: "integer"}}}},
+		{X: &schema.RawExpr{X: "(a + b)"}},
+		{X: &schema.RawExpr{X: "(a + (b * 2))"}},
+	}, key.Parts)
+}
+
 func TestDriver_InspectSchema(t *testing.T) {
 	db, m, err := sqlmock.New()
 	require.NoError(t, err)
@@ -315,7 +383,7 @@ test
 `))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(tablesQuery, "$1"))).
 		WithArgs("test").
-		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment", "partition_attrs", "partition_strategy", "partition_exprs"}))
 	s, err := drv.InspectSchema(context.Background(), "", &schema.InspectOptions{})
 	require.NoError(t, err)
 	require.EqualValues(t, func() *schema.Schema {
@@ -356,7 +424,7 @@ public
 `))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(tablesQuery, "$1, $2"))).
 		WithArgs("test", "public").
-		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment", "partition_attrs", "partition_strategy", "partition_exprs"}))
 	realm, err := drv.InspectRealm(context.Background(), &schema.InspectRealmOption{})
 	require.NoError(t, err)
 	require.EqualValues(t, func() *schema.Realm {
@@ -394,7 +462,7 @@ public
 `))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(tablesQuery, "$1, $2"))).
 		WithArgs("test", "public").
-		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment", "partition_attrs", "partition_strategy", "partition_exprs"}))
 	realm, err = drv.InspectRealm(context.Background(), &schema.InspectRealmOption{Schemas: []string{"test", "public"}})
 	require.NoError(t, err)
 	require.EqualValues(t, func() *schema.Realm {
@@ -431,7 +499,7 @@ public
 `))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(tablesQuery, "$1"))).
 		WithArgs("test").
-		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_schema", "table_name", "comment", "partition_attrs", "partition_strategy", "partition_exprs"}))
 	realm, err = drv.InspectRealm(context.Background(), &schema.InspectRealmOption{Schemas: []string{"test"}})
 	require.NoError(t, err)
 	require.EqualValues(t, func() *schema.Realm {
@@ -513,9 +581,9 @@ func (m mock) version(version string) {
 }
 
 func (m mock) tableExists(schema, table string, exists bool) {
-	rows := sqlmock.NewRows([]string{"table_schema", "table_name", "table_comment"})
+	rows := sqlmock.NewRows([]string{"table_schema", "table_name", "table_comment", "partition_attrs", "partition_strategy", "partition_exprs"})
 	if exists {
-		rows.AddRow(schema, table, nil)
+		rows.AddRow(schema, table, nil, nil, nil, nil)
 	}
 	m.ExpectQuery(queryTables).
 		WithArgs(schema).
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index dfebe0b0..cc39e46b 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -167,6 +167,7 @@ func TestPlanChanges(t *testing.T) {
 							&schema.Check{Name: "id_nonzero", Expr: `("id" > 0)`},
 							&schema.Check{Name: "text_len", Expr: `(length("text") > 0)`, Attrs: []schema.Attr{&NoInherit{}}},
 							&schema.Check{Name: "a_in_b", Expr: `(a) in (b)`},
+							&Partition{T: "HASH", Parts: []*PartitionPart{{C: schema.NewColumn("text")}}},
 						},
 					},
 				},
@@ -174,7 +175,7 @@ func TestPlanChanges(t *testing.T) {
 			plan: &migrate.Plan{
 				Reversible:    true,
 				Transactional: true,
-				Changes:       []*migrate.Change{{Cmd: `CREATE TABLE "posts" ("id" integer NOT NULL GENERATED BY DEFAULT AS IDENTITY, "text" text NULL, CONSTRAINT "id_nonzero" CHECK ("id" > 0), CONSTRAINT "text_len" CHECK (length("text") > 0) NO INHERIT, CONSTRAINT "a_in_b" CHECK ((a) in (b)))`, Reverse: `DROP TABLE "posts"`}},
+				Changes:       []*migrate.Change{{Cmd: `CREATE TABLE "posts" ("id" integer NOT NULL GENERATED BY DEFAULT AS IDENTITY, "text" text NULL, CONSTRAINT "id_nonzero" CHECK ("id" > 0), CONSTRAINT "text_len" CHECK (length("text") > 0) NO INHERIT, CONSTRAINT "a_in_b" CHECK ((a) in (b))) PARTITION BY HASH ("text")`, Reverse: `DROP TABLE "posts"`}},
 			},
 		},
 		{
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index cf3674f4..56a1fbbb 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -285,6 +285,156 @@ table "t" {
 	})
 }
 
+func TestUnmarshalSpec_Partitioned(t *testing.T) {
+	t.Run("Columns", func(t *testing.T) {
+		var (
+			s = &schema.Schema{}
+			f = `
+schema "test" {}
+table "logs" {
+	schema = schema.test
+	column "name" {
+		type = text
+	}
+	partition {
+		type = HASH
+		columns = [
+			column.name
+		]
+	}
+}
+`
+		)
+		err := UnmarshalHCL([]byte(f), s)
+		require.NoError(t, err)
+		c := schema.NewStringColumn("name", "text")
+		expected := schema.New("test").
+			AddTables(schema.NewTable("logs").AddColumns(c).AddAttrs(&Partition{T: PartitionTypeHash, Parts: []*PartitionPart{{C: c}}}))
+		require.Equal(t, expected, s)
+	})
+
+	t.Run("Parts", func(t *testing.T) {
+		var (
+			s = &schema.Schema{}
+			f = `
+schema "test" {}
+table "logs" {
+	schema = schema.test
+	column "name" {
+		type = text
+	}
+	partition {
+		type = RANGE
+		by {
+			column = column.name
+		}
+		by {
+			expr = "lower(name)"
+		}
+	}
+}
+`
+		)
+		err := UnmarshalHCL([]byte(f), s)
+		require.NoError(t, err)
+		c := schema.NewStringColumn("name", "text")
+		expected := schema.New("test").
+			AddTables(schema.NewTable("logs").AddColumns(c).AddAttrs(&Partition{T: PartitionTypeRange, Parts: []*PartitionPart{{C: c}, {X: &schema.RawExpr{X: "lower(name)"}}}}))
+		require.Equal(t, expected, s)
+	})
+
+	t.Run("Invalid", func(t *testing.T) {
+		err := UnmarshalHCL([]byte(`
+			schema "test" {}
+			table "logs" {
+				schema = schema.test
+				column "name" { type = text }
+				partition {
+					columns = [column.name]
+				}
+			}
+		`), &schema.Schema{})
+		require.EqualError(t, err, "missing attribute logs.partition.type")
+
+		err = UnmarshalHCL([]byte(`
+			schema "test" {}
+			table "logs" {
+				schema = schema.test
+				column "name" { type = text }
+				partition {
+					type = HASH
+				}
+			}
+		`), &schema.Schema{})
+		require.EqualError(t, err, `missing columns or expressions for logs.partition`)
+
+		err = UnmarshalHCL([]byte(`
+			schema "test" {}
+			table "logs" {
+				schema = schema.test
+				column "name" { type = text }
+				partition {
+					type = HASH
+					columns = [column.name]
+					by { column = column.name }
+				}
+			}
+		`), &schema.Schema{})
+		require.EqualError(t, err, `multiple definitions for logs.partition, use "columns" or "by"`)
+	})
+}
+
+func TestMarshalSpec_Partitioned(t *testing.T) {
+	t.Run("Columns", func(t *testing.T) {
+		c := schema.NewStringColumn("name", "text")
+		s := schema.New("test").
+			AddTables(schema.NewTable("logs").AddColumns(c).AddAttrs(&Partition{T: PartitionTypeHash, Parts: []*PartitionPart{{C: c}}}))
+		buf, err := MarshalHCL(s)
+		require.NoError(t, err)
+		require.Equal(t, `table "logs" {
+  schema = schema.test
+  column "name" {
+    null = false
+    type = text
+  }
+  partition {
+    type    = HASH
+    columns = [column.name]
+  }
+}
+schema "test" {
+}
+`, string(buf))
+	})
+
+	t.Run("Parts", func(t *testing.T) {
+		c := schema.NewStringColumn("name", "text")
+		s := schema.New("test").
+			AddTables(schema.NewTable("logs").AddColumns(c).AddAttrs(&Partition{T: PartitionTypeHash, Parts: []*PartitionPart{{C: c}, {X: &schema.RawExpr{X: "lower(name)"}}}}))
+		buf, err := MarshalHCL(s)
+		require.NoError(t, err)
+		require.Equal(t, `table "logs" {
+  schema = schema.test
+  column "name" {
+    null = false
+    type = text
+  }
+  partition {
+    type = HASH
+    by {
+      column = column.name
+    }
+    by {
+      expr = "lower(name)"
+    }
+  }
+}
+schema "test" {
+}
+`, string(buf))
+	})
+}
+
 func TestMarshalSpec_IndexPredicate(t *testing.T) {
 	s := &schema.Schema{
 		Name: "test",