diff --git a/src/lib/PostgresMetaColumns.ts b/src/lib/PostgresMetaColumns.ts
index 01897c06..468f14c9 100644
--- a/src/lib/PostgresMetaColumns.ts
+++ b/src/lib/PostgresMetaColumns.ts
@@ -2,7 +2,7 @@ import { ident, literal } from 'pg-format'
 import PostgresMetaTables from './PostgresMetaTables'
 import { DEFAULT_SYSTEM_SCHEMAS } from './constants'
 import { columnsSql } from './sql'
-import { PostgresMetaResult, PostgresColumn } from './types'
+import { PostgresMetaResult, PostgresColumn, PostgresColumnCreate } from './types'
 
 export default class PostgresMetaColumns {
   query: (sql: string) => Promise<PostgresMetaResult<any>>
@@ -57,75 +57,151 @@ export default class PostgresMetaColumns {
     schema?: string
   }): Promise<PostgresMetaResult<PostgresColumn>> {
     if (id) {
+      const { data, error } = await this.batchRetrieve({ ids: [id] })
+      if (data) {
+        return { data: data[0], error: null }
+      } else if (error) {
+        return { data: null, error: error }
+      }
+    }
+    if (name && table) {
+      const { data, error } = await this.batchRetrieve({ names: [name], table, schema })
+      if (data) {
+        return { data: data[0], error: null }
+      } else if (error) {
+        return { data: null, error: error }
+      }
+    }
+    return { data: null, error: { message: 'Invalid parameters on column retrieve' } }
+  }
+
+  async batchRetrieve({ ids }: { ids: string[] }): Promise<PostgresMetaResult<PostgresColumn[]>>
+  async batchRetrieve({
+    names,
+    table,
+    schema,
+  }: {
+    names: string[]
+    table: string
+    schema: string
+  }): Promise<PostgresMetaResult<PostgresColumn[]>>
+  async batchRetrieve({
+    ids,
+    names,
+    table,
+    schema = 'public',
+  }: {
+    ids?: string[]
+    names?: string[]
+    table?: string
+    schema?: string
+  }): Promise<PostgresMetaResult<PostgresColumn[]>> {
+    if (ids && ids.length > 0) {
       const regexp = /^(\d+)\.(\d+)$/
-      if (!regexp.test(id)) {
-        return { data: null, error: { message: 'Invalid format for column ID' } }
+
+      const invalidIds = ids.filter((id) => !regexp.test(id))
+      if (invalidIds.length > 0) {
+        return {
+          data: null,
+          error: { message: `Invalid format for column IDs: ${invalidIds.join(', ')}` },
+        }
       }
-      const matches = id.match(regexp) as RegExpMatchArray
-      const [tableId, ordinalPos] = matches.slice(1).map(Number)
-      const sql = `${columnsSql} AND c.oid = ${tableId} AND a.attnum = ${ordinalPos};`
+
+      const filteringClauses = ids
+        .map((id) => {
+          const matches = id.match(regexp) as RegExpMatchArray
+          const [tableId, ordinalPos] = matches.slice(1).map(Number)
+          return `(c.oid = ${tableId} AND a.attnum = ${ordinalPos})`
+        })
+        .join(' OR ')
+      const sql = `${columnsSql} AND (${filteringClauses});`
       const { data, error } = await this.query(sql)
       if (error) {
         return { data, error }
-      } else if (data.length === 0) {
-        return { data: null, error: { message: `Cannot find a column with ID ${id}` } }
+      } else if (data.length < ids.length) {
+        return { data: null, error: { message: `Cannot find some of the requested columns.` } }
       } else {
-        return { data: data[0], error }
+        return { data, error }
       }
-    } else if (name && table) {
-      const sql = `${columnsSql} AND a.attname = ${literal(name)} AND c.relname = ${literal(
+    } else if (names && names.length > 0 && table) {
+      const filteringClauses = names.map((name) => `a.attname = ${literal(name)}`).join(' OR ')
+      const sql = `${columnsSql} AND (${filteringClauses}) AND c.relname = ${literal(
         table
       )} AND nc.nspname = ${literal(schema)};`
       const { data, error } = await this.query(sql)
       if (error) {
         return { data, error }
-      } else if (data.length === 0) {
+      } else if (data.length < names.length) {
         return {
           data: null,
-          error: { message: `Cannot find a column named ${name} in table ${schema}.${table}` },
+          error: { message: `Cannot find some of the requested columns.` },
         }
       } else {
-        return { data: data[0], error }
+        return { data, error }
       }
     } else {
       return { data: null, error: { message: 'Invalid parameters on column retrieve' } }
     }
   }
 
-  async create({
-    table_id,
-    name,
-    type,
-    default_value,
-    default_value_format = 'literal',
-    is_identity = false,
-    identity_generation = 'BY DEFAULT',
-    // Can't pick a value as default since regular columns are nullable by default but PK columns aren't
-    is_nullable,
-    is_primary_key = false,
-    is_unique = false,
-    comment,
-    check,
-  }: {
-    table_id: number
-    name: string
-    type: string
-    default_value?: any
-    default_value_format?: 'expression' | 'literal'
-    is_identity?: boolean
-    identity_generation?: 'BY DEFAULT' | 'ALWAYS'
-    is_nullable?: boolean
-    is_primary_key?: boolean
-    is_unique?: boolean
-    comment?: string
-    check?: string
-  }): Promise<PostgresMetaResult<PostgresColumn>> {
+  async create(col: PostgresColumnCreate): Promise<PostgresMetaResult<PostgresColumn>> {
+    const { data, error } = await this.batchCreate([col])
+    if (data) {
+      return { data: data[0], error: null }
+    } else if (error) {
+      return { data: null, error: error }
+    }
+    return { data: null, error: { message: 'Invalid params' } }
+  }
+
+  async batchCreate(cols: PostgresColumnCreate[]): Promise<PostgresMetaResult<PostgresColumn[]>> {
+    if (cols.length < 1) {
+      throw new Error('no columns provided for creation')
+    }
+    if ([...new Set(cols.map((col) => col.table_id))].length > 1) {
+      throw new Error('all columns in a single request must share the same table')
+    }
+    const { table_id } = cols[0]
     const { data, error } = await this.metaTables.retrieve({ id: table_id })
     if (error) {
       return { data: null, error }
     }
     const { name: table, schema } = data!
 
+    const sqlStrings = cols.map((col) => this.generateColumnCreationSql(col, schema, table))
+
+    const sql = `BEGIN;
+${sqlStrings.join('\n')}
+COMMIT;
+`
+    {
+      const { error } = await this.query(sql)
+      if (error) {
+        return { data: null, error }
+      }
+    }
+    const names = cols.map((col) => col.name)
+    return await this.batchRetrieve({ names, table, schema })
+  }
+
+  generateColumnCreationSql(
+    {
+      name,
+      type,
+      default_value,
+      default_value_format = 'literal',
+      is_identity = false,
+      identity_generation = 'BY DEFAULT',
+      // Can't pick a value as default since regular columns are nullable by default but PK columns aren't
+      is_nullable,
+      is_primary_key = false,
+      is_unique = false,
+      comment,
+      check,
+    }: PostgresColumnCreate,
+    schema: string,
+    table: string
+  ) {
     let defaultValueClause = ''
     if (is_identity) {
       if (default_value !== undefined) {
@@ -159,22 +235,14 @@ export default class PostgresMetaColumns {
         : `COMMENT ON COLUMN ${ident(schema)}.${ident(table)}.${ident(name)} IS ${literal(comment)}`
 
     const sql = `
-BEGIN;
   ALTER TABLE ${ident(schema)}.${ident(table)} ADD COLUMN ${ident(name)} ${typeIdent(type)}
     ${defaultValueClause}
     ${isNullableClause}
     ${isPrimaryKeyClause}
     ${isUniqueClause}
     ${checkSql};
-  ${commentSql};
-COMMIT;`
-    {
-      const { error } = await this.query(sql)
-      if (error) {
-        return { data: null, error }
-      }
-    }
-    return await this.retrieve({ name, table, schema })
+  ${commentSql};`
+    return sql
   }
 
   async update(
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 815eb796..1da4ca4a 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -42,6 +42,26 @@ export const postgresColumnSchema = Type.Object({
 })
 export type PostgresColumn = Static<typeof postgresColumnSchema>
 
+export const postgresColumnCreateSchema = Type.Object({
+  table_id: Type.Integer(),
+  name: Type.String(),
+  type: Type.String(),
+  default_value: Type.Optional(Type.Any()),
+  default_value_format: Type.Optional(
+    Type.Union([Type.Literal('expression'), Type.Literal('literal')])
+  ),
+  is_identity: Type.Optional(Type.Boolean()),
+  identity_generation: Type.Optional(
+    Type.Union([Type.Literal('BY DEFAULT'), Type.Literal('ALWAYS')])
+  ),
+  is_nullable: Type.Optional(Type.Boolean()),
+  is_primary_key: Type.Optional(Type.Boolean()),
+  is_unique: Type.Optional(Type.Boolean()),
+  comment: Type.Optional(Type.String()),
+  check: Type.Optional(Type.String()),
+})
+export type PostgresColumnCreate = Static<typeof postgresColumnCreateSchema>
+
 // TODO Rethink config.sql
 export const postgresConfigSchema = Type.Object({
   name: Type.Unknown(),
diff --git a/src/server/routes/columns.ts b/src/server/routes/columns.ts
index 9451702b..b12b9d97 100644
--- a/src/server/routes/columns.ts
+++ b/src/server/routes/columns.ts
@@ -1,5 +1,11 @@
+import { Type } from '@sinclair/typebox'
 import { FastifyInstance } from 'fastify'
 import { PostgresMeta } from '../../lib'
+import {
+  PostgresColumnCreate,
+  postgresColumnSchema,
+  postgresColumnCreateSchema,
+} from '../../lib/types'
 import { DEFAULT_POOL_CONFIG } from '../constants'
 import { extractRequestForLogging } from '../utils'
 
@@ -56,22 +62,51 @@ export default async (fastify: FastifyInstance) => {
 
   fastify.post<{
     Headers: { pg: string }
-    Body: any
-  }>('/', async (request, reply) => {
-    const connectionString = request.headers.pg
+    Body: PostgresColumnCreate | PostgresColumnCreate[]
+  }>(
+    '/',
+    {
+      schema: {
+        headers: Type.Object({
+          pg: Type.String(),
+        }),
+        body: Type.Union([postgresColumnCreateSchema, Type.Array(postgresColumnCreateSchema)]),
+        response: {
+          200: Type.Union([postgresColumnSchema, Type.Array(postgresColumnSchema)]),
+          400: Type.Object({
+            error: Type.String(),
+          }),
+          404: Type.Object({
+            error: Type.String(),
+          }),
+        },
+      },
+    },
+    async (request, reply) => {
+      const connectionString = request.headers.pg
+      let batchCreateArg: PostgresColumnCreate[]
+      if (Array.isArray(request.body)) {
+        batchCreateArg = request.body
+      } else {
+        batchCreateArg = [request.body]
+      }
 
-    const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
-    const { data, error } = await pgMeta.columns.create(request.body)
-    await pgMeta.end()
-    if (error) {
-      request.log.error({ error, request: extractRequestForLogging(request) })
-      reply.code(400)
-      if (error.message.startsWith('Cannot find')) reply.code(404)
-      return { error: error.message }
-    }
+      const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
+      const { data, error } = await pgMeta.columns.batchCreate(batchCreateArg)
+      await pgMeta.end()
+      if (error) {
+        request.log.error({ error, request: extractRequestForLogging(request) })
+        reply.code(400)
+        if (error.message.startsWith('Cannot find')) reply.code(404)
+        return { error: error.message }
+      }
 
-    return data
-  })
+      if (Array.isArray(request.body)) {
+        return data
+      }
+      return data[0]
+    }
+  )
 
   fastify.patch<{
     Headers: { pg: string }
diff --git a/test/lib/columns.ts b/test/lib/columns.ts
index 00477274..60b076d0 100644
--- a/test/lib/columns.ts
+++ b/test/lib/columns.ts
@@ -170,13 +170,143 @@ test('retrieve, create, update, delete', async () => {
   expect(res).toMatchObject({
     data: null,
     error: {
-      message: expect.stringMatching(/^Cannot find a column with ID \d+.1$/),
+      message: expect.stringMatching(/^Cannot find some of the requested columns.$/),
     },
   })
 
   await pgMeta.tables.remove(testTable!.id)
 })
 
+test('batch endpoints for create and retrieve', async () => {
+  const { data: testTable }: any = await pgMeta.tables.create({ name: 't' })
+
+  let res = await pgMeta.columns.batchCreate([
+    {
+      table_id: testTable!.id,
+      name: 'c1',
+      type: 'int2',
+      default_value: 42,
+      comment: 'foo',
+    },
+    {
+      table_id: testTable!.id,
+      name: 'c2',
+      type: 'int2',
+      default_value: 41,
+      comment: 'bar',
+    },
+  ])
+  expect(res).toMatchInlineSnapshot(
+    {
+      data: [
+        { id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) },
+        { id: expect.stringMatching(/^\d+\.2$/), table_id: expect.any(Number) },
+      ],
+    },
+    `
+    Object {
+      "data": Array [
+        Object {
+          "comment": "foo",
+          "data_type": "smallint",
+          "default_value": "'42'::smallint",
+          "enums": Array [],
+          "format": "int2",
+          "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/,
+          "identity_generation": null,
+          "is_generated": false,
+          "is_identity": false,
+          "is_nullable": true,
+          "is_unique": false,
+          "is_updatable": true,
+          "name": "c1",
+          "ordinal_position": 1,
+          "schema": "public",
+          "table": "t",
+          "table_id": Any<Number>,
+        },
+        Object {
+          "comment": "bar",
+          "data_type": "smallint",
+          "default_value": "'41'::smallint",
+          "enums": Array [],
+          "format": "int2",
+          "id": StringMatching /\\^\\\\d\\+\\\\\\.2\\$/,
+          "identity_generation": null,
+          "is_generated": false,
+          "is_identity": false,
+          "is_nullable": true,
+          "is_unique": false,
+          "is_updatable": true,
+          "name": "c2",
+          "ordinal_position": 2,
+          "schema": "public",
+          "table": "t",
+          "table_id": Any<Number>,
+        },
+      ],
+      "error": null,
+    }
+  `
+  )
+  res = await pgMeta.columns.batchRetrieve({ ids: [res.data![0].id, res.data![1].id] })
+  expect(res).toMatchInlineSnapshot(
+    {
+      data: [
+        { id: expect.stringMatching(/^\d+\.1$/), table_id: expect.any(Number) },
+        { id: expect.stringMatching(/^\d+\.2$/), table_id: expect.any(Number) },
+      ],
+    },
+    `
+    Object {
+      "data": Array [
+        Object {
+          "comment": "foo",
+          "data_type": "smallint",
+          "default_value": "'42'::smallint",
+          "enums": Array [],
+          "format": "int2",
+          "id": StringMatching /\\^\\\\d\\+\\\\\\.1\\$/,
+          "identity_generation": null,
+          "is_generated": false,
+          "is_identity": false,
+          "is_nullable": true,
+          "is_unique": false,
+          "is_updatable": true,
+          "name": "c1",
+          "ordinal_position": 1,
+          "schema": "public",
+          "table": "t",
+          "table_id": Any<Number>,
+        },
+        Object {
+          "comment": "bar",
+          "data_type": "smallint",
+          "default_value": "'41'::smallint",
+          "enums": Array [],
+          "format": "int2",
+          "id": StringMatching /\\^\\\\d\\+\\\\\\.2\\$/,
+          "identity_generation": null,
+          "is_generated": false,
+          "is_identity": false,
+          "is_nullable": true,
+          "is_unique": false,
+          "is_updatable": true,
+          "name": "c2",
+          "ordinal_position": 2,
+          "schema": "public",
+          "table": "t",
+          "table_id": Any<Number>,
+        },
+      ],
+      "error": null,
+    }
+  `
+  )
+
+  await pgMeta.tables.remove(testTable!.id)
+})
+
 test('enum column with quoted name', async () => {
   await pgMeta.query('CREATE TYPE "T" AS ENUM (\'v\'); CREATE TABLE t ( c "T" );')