diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fa2a40c..53c11a0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 ## Unreleased
 
 ### Added
+- Move Tables-kt to DataForge repository and make it follow DataForge versioning
 
 ### Changed
 
diff --git a/build.gradle.kts b/build.gradle.kts
index cba70f11..f5142512 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,7 +9,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.10.1"
+    version = "0.10.2-dev"
 }
 
 subprojects {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 00000000..3b019e7d
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,13 @@
+[versions]
+
+dataframe = "0.15.0"
+exposed = "0.60.0"
+
+[libraries]
+attributes = "space.kscience:attributes-kt:0.3.0"
+kotlinx-dataframe = { module = "org.jetbrains.kotlinx:dataframe", version.ref = "dataframe" }
+csv = "com.jsoizo:kotlin-csv:1.10.0"
+exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
+exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
+
+[plugins]
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 35eae74e..d40535d3 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -47,5 +47,9 @@ include(
     ":dataforge-context",
     ":dataforge-data",
     ":dataforge-workspace",
-    ":dataforge-scripting"
+    ":dataforge-scripting",
+    ":tables-kt:tables-kt-exposed",
+    ":tables-kt:tables-kt-dataframe",
+    ":tables-kt:tables-kt-jupyter",
+    ":tables-kt:tables-kt-csv"
 )
\ No newline at end of file
diff --git a/tables-kt/README.md b/tables-kt/README.md
new file mode 100644
index 00000000..1f3684e8
--- /dev/null
+++ b/tables-kt/README.md
@@ -0,0 +1,52 @@
+[![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
+
+# Tables.kt
+
+Tables.kt is a lightweight Kotlin-Multiplatform library to work with tables of any origin. It is **not** intended as an alternative to [DataFrame](https://github.com/Kotlin/dataframe) library. On the contrary, you can use it together with the [provided module](tables-lt-dataframe). The aim of this library to provide an API for a various tables and row lists.
+
+Another aim is to provide integration between different types of tables. For example load database table with Exposed and convert it to a DataFrame.
+
+The performance could vary depending on the type of the table. For example row-based access to column-based table could be slow and vise-versa. Though in principle, the implementations could be tweaked to be very fast.
+
+The library is intended as multiplatform. It supports JVM, JS-IR and Native targets.
+
+## Installation
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:tables-kt:0.4.1`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:tables-kt:0.4.1")
+}
+```
+
+## Features
+
+
+
+## Modules
+
+
+### [tables-kt-csv](tables-kt-csv)
+>
+> **Maturity**: EXPERIMENTAL
+
+### [tables-kt-dataframe](tables-kt-dataframe)
+>
+> **Maturity**: PROTOTYPE
+
+### [tables-kt-exposed](tables-kt-exposed)
+>
+> **Maturity**: EXPERIMENTAL
+
+### [tables-kt-jupyter](tables-kt-jupyter)
+>
+> **Maturity**: EXPERIMENTAL
diff --git a/tables-kt/build.gradle.kts b/tables-kt/build.gradle.kts
new file mode 100644
index 00000000..4ca9ec39
--- /dev/null
+++ b/tables-kt/build.gradle.kts
@@ -0,0 +1,26 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = "A lightweight multiplatform library for tables"
+
+allprojects {
+    group = "space.kscience"
+    version = "0.4.1"
+}
+
+kscience{
+    jvm()
+    js()
+    native()
+    wasm()
+    useContextReceivers()
+    dependencies {
+        api(projects.dataforgeIo)
+    }
+}
+
+readme {
+    maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
+}
diff --git a/tables-kt/docs/README-TEMPLATE.md b/tables-kt/docs/README-TEMPLATE.md
new file mode 100644
index 00000000..64e55349
--- /dev/null
+++ b/tables-kt/docs/README-TEMPLATE.md
@@ -0,0 +1,23 @@
+[![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
+
+# Tables.kt
+
+Tables.kt is a lightweight Kotlin-Multiplatform library to work with tables of any origin. It is **not** intended as an alternative to [DataFrame](https://github.com/Kotlin/dataframe) library. On the contrary, you can use it together with the [provided module](tables-lt-dataframe). The aim of this library to provide an API for a various tables and row lists.
+
+Another aim is to provide integration between different types of tables. For example load database table with Exposed and convert it to a DataFrame.
+
+The performance could vary depending on the type of the table. For example row-based access to column-based table could be slow and vise-versa. Though in principle, the implementations could be tweaked to be very fast.
+
+The library is intended as multiplatform. It supports JVM, JS-IR and Native targets.
+
+## Installation
+
+${artifact}
+
+## Features
+
+${features}
+
+## Modules
+
+${modules}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnHeader.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnHeader.kt
new file mode 100644
index 00000000..c1307f14
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnHeader.kt
@@ -0,0 +1,73 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.*
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+public typealias TableHeader<C> = List<ColumnHeader<C>>
+
+public typealias ValueTableHeader = List<ColumnHeader<Value>>
+
+/**
+ * A header for a column including [name], column [type] and column metadata
+ */
+public interface ColumnHeader<out T> {
+    public val name: String
+    public val type: KType
+
+    /**
+     * Column metadata. Common structure defined by [ColumnScheme]
+     */
+    public val meta: Meta
+
+    public companion object {
+        /**
+         * A delegated builder for typed column header
+         */
+        public inline fun <reified T> typed(
+            crossinline builder: ColumnScheme.() -> Unit = {},
+        ): ReadOnlyProperty<Any?, ColumnHeader<T>> = ReadOnlyProperty { _, property ->
+            ColumnHeader(property.name, builder)
+        }
+
+        /**
+         * A delegate for a [Value] based column header
+         */
+        public fun value(
+            valueType: ValueType = ValueType.STRING,
+            builder: ValueColumnScheme.() -> Unit = {},
+        ): ReadOnlyProperty<Any?, ColumnHeader<Value>> = ReadOnlyProperty { _, property ->
+            ColumnHeader(property.name, valueType, builder)
+        }
+    }
+}
+
+public inline fun <reified T> ColumnHeader(
+    name: String,
+    builder: ColumnScheme.() -> Unit = {},
+): ColumnHeader<T> = SimpleColumnHeader(name, typeOf<T>(), ColumnScheme(builder).meta)
+
+/**
+ * Create a [Value]-typed column header
+ */
+public fun ColumnHeader(
+    name: String,
+    valueType: ValueType,
+    builder: ValueColumnScheme.() -> Unit = {},
+): ColumnHeader<Value> = SimpleColumnHeader(
+    name, typeOf<Value>(), ValueColumnScheme {
+        this.valueType = valueType
+        builder()
+    }.meta
+)
+
+public data class SimpleColumnHeader<T>(
+    override val name: String,
+    override val type: KType,
+    override val meta: Meta,
+) : ColumnHeader<T>
+
+
+public val ColumnHeader<Value>.valueType: ValueType?
+    get() = meta[ValueColumnScheme::valueType.name].enum<ValueType>()
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnScheme.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnScheme.kt
new file mode 100644
index 00000000..410ac53c
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnScheme.kt
@@ -0,0 +1,19 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.*
+
+public open class ColumnScheme : Scheme() {
+    public var title: String? by string()
+
+    public companion object : SchemeSpec<ColumnScheme>(::ColumnScheme)
+}
+
+public val ColumnHeader<*>.properties: ColumnScheme get() =  ColumnScheme.read(meta)
+
+public class ValueColumnScheme : ColumnScheme() {
+    public var valueType: ValueType by enum(ValueType.STRING)
+
+    public companion object : SchemeSpec<ValueColumnScheme>(::ValueColumnScheme)
+}
+
+public val ColumnHeader<Value>.properties: ValueColumnScheme get() =  ValueColumnScheme.read(meta)
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnTable.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnTable.kt
new file mode 100644
index 00000000..af503a11
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnTable.kt
@@ -0,0 +1,47 @@
+package space.kscience.tables
+
+/**
+ * A table based on column-wise data representation.
+ *
+ * @param T boundary type for all columns in the table
+ */
+public open class ColumnTable<out T>(final override val columns: Collection<Column<T>>) : Table<T> {
+    private val rowsNum get() = columns.first().size
+
+    init {
+        require(columns.all { it.size == rowsNum }) { "All columns must be of the same size" }
+    }
+
+    override val rows: List<Row<T>>
+        get() = (0 until rowsNum).map { VirtualRow(this, it) }
+
+    override fun getOrNull(row: Int, column: String): T? = columns[column].getOrNull(row)
+}
+
+public fun <T> ColumnTable(vararg columns: Column<T>): ColumnTable<T> = ColumnTable(columns.toList())
+
+public inline fun <T> ColumnTable(size: Int, builder: ColumnTableBuilder<T>.() -> Unit): ColumnTable<T> =
+    ColumnTableBuilder<T>(size).apply(builder)
+
+public val <T> Table<T>.rowsSize: Int get() = columns.firstOrNull()?.size ?: 0
+
+public operator fun <T, R : T> Collection<Column<T>>.get(header: ColumnHeader<R>): Column<R> {
+    val res = find { it.name == header.name } ?: error("Column with name ${header.name} not present")
+    require(header.type == res.type) { "Column type mismatch. Expected ${header.type}, but found ${res.type}" }
+    @Suppress("UNCHECKED_CAST") return res as Column<R>
+}
+
+internal class VirtualRow<T>(val table: Table<T>, val index: Int) : Row<T> {
+    override fun getOrNull(column: String): T? = table.getOrNull(index, column)
+}
+
+/**
+ * Convert table to a column-based representation or return itself if it is already column-based.
+ * This method is used only for performance.
+ *
+ * The resulting table does not in general follow changes of the initial table.
+ */
+public fun <T> Table<T>.toColumnTable(): ColumnTable<T> = (this as? ColumnTable<T>) ?: ColumnTable(
+    columns.map { c -> c as? ListColumn ?: ListColumn(c, c.listValues()) }
+)
+
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnTableBuilder.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnTableBuilder.kt
new file mode 100644
index 00000000..019d99b5
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ColumnTableBuilder.kt
@@ -0,0 +1,111 @@
+package space.kscience.tables
+
+/**
+ * A table with columns that could be reordered. Column content could not be changed after creation.
+ */
+public class ColumnTableBuilder<T>(
+    public val rowsSize: Int,
+    private val _columns: MutableList<Column<T>> = ArrayList(),
+) : ColumnTable<T>(_columns) {
+
+    override val rows: List<Row<T>>
+        get() = (0 until rowsSize).map {
+            VirtualRow(this, it)
+        }
+
+    override fun getOrNull(row: Int, column: String): T? = columns.getOrNull(column)?.getOrNull(row)
+
+    /**
+     * Add or insert at [index] a fixed column
+     */
+    public fun addColumn(column: Column<T>, index: Int? = null) {
+        require(column.size == this.rowsSize) { "Required column size $rowsSize, but found ${column.size}" }
+        require(_columns.find { it.name == column.name } == null) { "Column with name ${column.name} already exists" }
+        if (index == null) {
+            _columns.add(column)
+        } else {
+            _columns.add(index, column)
+        }
+    }
+
+    /**
+     * Remove a column
+     */
+    public fun removeColumn(name: String) {
+        _columns.removeAll { it.name == name }
+    }
+
+    /**
+     * Get or set values for given column. The size of column must be the same as table [rowsNum]
+     */
+    public var <R : T> ColumnHeader<R>.values: Collection<R?>
+        get() = columns[this].listValues()
+        set(value) {
+            val newColumn = ListColumn(this, value.toList())
+            removeColumn(name)
+            addColumn(newColumn)
+        }
+}
+
+/**
+ * Set or replace column using given [expression]
+ */
+public fun <T, R : T> ColumnTableBuilder<T>.transform(
+    header: ColumnHeader<R>,
+    index: Int? = null,
+    expression: (Row<T>) -> R,
+) {
+    val column = rowsToColumn(header, false, expression)
+    removeColumn(header.name)
+    addColumn(column, index)
+}
+
+/**
+ * Set or replace column using column name
+ */
+public inline fun <T, reified R : T> ColumnTableBuilder<T>.transform(
+    name: String,
+    index: Int? = null,
+    noinline expression: (Row<T>) -> R,
+): Unit = transform(ColumnHeader<R>(name), index, expression)
+
+/**
+ * Adds or replaces a column in the ColumnTableBuilder with the given header and data.
+ *
+ * @param header the header of the column to be added or replaced
+ * @param data the data for the column
+ */
+public fun <T, R : T> ColumnTableBuilder<T>.column(header: ColumnHeader<R>, data: Iterable<R>) {
+    removeColumn(header.name)
+    val column = ListColumn(header.name, data.toList(), header.type, header.meta)
+    addColumn(column)
+}
+
+/**
+ * Adds a column with the given header to the table. Optionally, the column can be inserted at a specific index.
+ * The column is filled with data using the provided data builder function.
+ *
+ * @param header The header of the column to be added.
+ * @param index The index at which the column should be inserted. If null, the column is added to the end of the table.
+ * @param dataBuilder A function that takes an index and returns the data to fill the column at that index.
+ * @return The newly added column.
+ */
+public fun <T, R : T> ColumnTableBuilder<T>.fill(header: ColumnHeader<R>, index: Int? = null, dataBuilder: (Int) -> R?): Column<R> {
+    //TODO use specialized columns if possible
+    val column = ListColumn(header, rowsSize, dataBuilder)
+    addColumn(column, index)
+    return column
+}
+
+/**
+ * Shallow copy table to a new [ColumnTableBuilder]
+ */
+public fun <T> ColumnTable<T>.builder(): ColumnTableBuilder<T> =
+    ColumnTableBuilder<T>(rowsSize, columns.toMutableList())
+
+
+/**
+ * Shallow copy and edit [Table] and edit it as [ColumnTable]
+ */
+public fun <T> Table<T>.withColumns(block: ColumnTableBuilder<T>.() -> Unit): ColumnTable<T> =
+    ColumnTableBuilder<T>(rowsSize, columns.toMutableList()).apply(block)
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/ListColumn.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ListColumn.kt
new file mode 100644
index 00000000..13e5759b
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/ListColumn.kt
@@ -0,0 +1,48 @@
+@file:Suppress("FunctionName")
+
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+/**
+ * A column with data represented as [List]. Could have missing data
+ */
+public class ListColumn<T>(
+    override val name: String,
+    public val data: List<T?>,
+    override val type: KType,
+    override val meta: Meta,
+) : Column<T> {
+    override val size: Int get() = data.size
+
+    override fun getOrNull(index: Int): T? = if (index in data.indices) data[index] else null
+}
+
+public fun <T> ListColumn(header: ColumnHeader<T>, data: List<T?>): ListColumn<T> =
+    ListColumn(header.name, data, header.type, header.meta)
+
+public inline fun <reified T> ListColumn(
+    name: String,
+    def: ColumnScheme,
+    data: List<T?>,
+): ListColumn<T> = ListColumn(name, data, typeOf<T>(), def.toMeta())
+
+public fun <T> ListColumn(
+    header: ColumnHeader<T>,
+    size: Int,
+    dataBuilder: (Int) -> T?,
+): ListColumn<T> = ListColumn(header.name, List(size, dataBuilder), header.type, header.meta)
+
+public inline fun <reified T> ListColumn(
+    name: String,
+    def: ColumnScheme,
+    size: Int,
+    dataBuilder: (Int) -> T?,
+): ListColumn<T> = ListColumn(name, List(size, dataBuilder), typeOf<T>(), def.toMeta())
+
+public inline fun <T, reified R : Any> Column<T>.map(meta: Meta = this.meta, noinline block: (T?) -> R): Column<R> {
+    val data = List(size) { block(getOrNull(it)) }
+    return ListColumn(name, data, typeOf<R>(), meta)
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/MutableTable.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/MutableTable.kt
new file mode 100644
index 00000000..94d8bcfb
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/MutableTable.kt
@@ -0,0 +1,22 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Value
+
+/**
+ * A table with random-access mutable cells
+ */
+public interface MutableTable<T> : Table<T> {
+    public operator fun set(row: Int, column: String, value: T?)
+}
+
+public operator fun <T, R : T> MutableTable<T>.set(row: Int, column: ColumnHeader<R>, value: R?) {
+    set(row, column.name, value)
+}
+
+public operator fun <T> MutableTable<T>.set(column: String, values: Iterable<T>) {
+    values.forEachIndexed { index, value -> set(index, column, value) }
+}
+
+public operator fun MutableTable<Value>.set(row: Int, column: String, value: Any?) {
+    set(row, column, Value.of(value))
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/RowTable.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/RowTable.kt
new file mode 100644
index 00000000..5469cc7c
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/RowTable.kt
@@ -0,0 +1,84 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaRepr
+import space.kscience.dataforge.meta.Value
+import space.kscience.dataforge.meta.getValue
+import kotlin.jvm.JvmInline
+import kotlin.reflect.KType
+
+/**
+ * A [Row] of data represented by map.
+ */
+@JvmInline
+public value class MapRow<C>(public val values: Map<String, C?>) : Row<C> {
+    override fun getOrNull(column: String): C? = values[column]
+}
+
+/**
+ * Create a [MapRow] using pairs of [ColumnHeader] and values
+ */
+@Suppress("FunctionName")
+public fun <T> Row(vararg pairs: Pair<ColumnHeader<T>, T>): MapRow<T> =
+    MapRow(pairs.associate { it.first.name to it.second })
+
+/**
+ * A [Row] represented by [Meta]
+ */
+@JvmInline
+public value class MetaRow(public val meta: Meta) : Row<Value> {
+    override fun getOrNull(column: String): Value? = meta.getValue(column)
+}
+
+/**
+ * Represent [MetaRepr] as a [Row]
+ */
+public fun MetaRepr.asRow(): MetaRow = MetaRow(toMeta())
+
+/**
+ * A column in a [RowTable]
+ */
+internal class RowTableColumn<T, R : T>(val table: Table<T>, val header: ColumnHeader<R>) : Column<R> {
+    init {
+        require(header in table.headers) { "Header $header does not belong to $table" }
+    }
+
+    override val name: String get() = header.name
+    override val type: KType get() = header.type
+    override val meta: Meta get() = header.meta
+    override val size: Int get() = table.rows.size
+
+    @Suppress("UNCHECKED_CAST")
+    override fun getOrNull(index: Int): R? = table.getOrNull(index, name)?.let { it as R }
+}
+
+/**
+ * A row-based table
+ */
+public open class RowTable<C>(
+    override val headers: List<ColumnHeader<C>>,
+    override val rows: List<Row<C>>,
+) : Table<C> {
+    override fun getOrNull(row: Int, column: String): C? = rows[row].getOrNull(column)
+
+    override val columns: List<Column<C>> get() = headers.map { RowTableColumn(this, it) }
+}
+
+/**
+ * Create Row table with given headers
+ */
+public inline fun <T> RowTable(vararg headers: ColumnHeader<T>, block: RowTableBuilder<T>.() -> Unit): RowTable<T> =
+    RowTableBuilder<T>(arrayListOf(), headers.toMutableList()).apply(block)
+
+/**
+ * Collect [Rows] to a [Table]
+ */
+public fun <C> Rows<C>.collect(): Table<C> = this as? Table<C> ?: RowTable(headers, rowSequence().toList())
+
+/**
+ * If this is a [RowTable], return this, otherwise create a new [Row]-based table from its rows.
+ * This method is used only for performance.
+ *
+ * The resulting table does not in general follow changes of the initial table.
+ */
+public fun <T> Table<T>.toRowTable(): RowTable<T> = this as? RowTable<T> ?: RowTable(headers, rows)
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/RowTableBuilder.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/RowTableBuilder.kt
new file mode 100644
index 00000000..4d021d31
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/RowTableBuilder.kt
@@ -0,0 +1,107 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.Value
+import space.kscience.dataforge.meta.ValueType
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+public class RowTableBuilder<C>(
+    override val rows: MutableList<Row<C>>,
+    override val headers: MutableList<ColumnHeader<C>>,
+) : RowTable<C>(headers, rows) {
+
+    public fun <T : C> addColumn(header: ColumnHeader<C>) {
+        headers.add(header)
+    }
+
+    /**
+     * Create a new column header for the table
+     */
+    @PublishedApi
+    internal fun <T : C> newColumn(name: String, type: KType, meta: Meta, index: Int?): ColumnHeader<T> {
+        val header = SimpleColumnHeader<T>(name, type, meta)
+        if (index == null) {
+            headers.add(header)
+        } else {
+            headers.add(index, header)
+        }
+        return header
+    }
+
+    public fun addRow(row: Row<C>): Row<C> {
+        rows.add(row)
+        return row
+    }
+
+    public inline fun <reified T : C> newColumn(
+        name: String,
+        index: Int? = null,
+        noinline columnMetaBuilder: ColumnScheme.() -> Unit = {},
+    ): ColumnHeader<T> = newColumn(name, typeOf<T>(), ColumnScheme(columnMetaBuilder).toMeta(), index)
+
+    public inline fun <reified T : C> column(
+        index: Int? = null,
+        noinline columnMetaBuilder: ColumnScheme.() -> Unit = {},
+    ): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, ColumnHeader<T>>> =
+        PropertyDelegateProvider { _, property ->
+            val res: ColumnHeader<T> = newColumn(property.name, index, columnMetaBuilder)
+            ReadOnlyProperty { _, _ -> res }
+        }
+
+    public fun row(map: Map<String, C?>): Row<C> {
+        val row = MapRow(map)
+        rows.add(row)
+        return row
+    }
+
+    public fun <T : C> row(vararg pairs: Pair<ColumnHeader<T>, T>): Row<C> = addRow(Row(*pairs))
+}
+
+public fun RowTableBuilder<in Value>.newColumn(
+    name: String,
+    valueType: ValueType,
+    index: Int? = null,
+    columnMetaBuilder: ValueColumnScheme.() -> Unit = {},
+): ColumnHeader<Value> = newColumn(
+    name,
+    typeOf<Value>(),
+    ValueColumnScheme(columnMetaBuilder).also { it.valueType = valueType }.toMeta(),
+    index
+)
+
+public fun RowTableBuilder<in Value>.column(
+    valueType: ValueType,
+    index: Int? = null,
+    columnMetaBuilder: ValueColumnScheme.() -> Unit = {},
+): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, ColumnHeader<Value>>> =
+    PropertyDelegateProvider { _, property ->
+        val res = newColumn(property.name, valueType, index, columnMetaBuilder)
+        ReadOnlyProperty { _, _ -> res }
+    }
+
+public fun RowTableBuilder<Value>.valueRow(vararg pairs: Pair<ColumnHeader<Value>, Any?>): Row<Value> =
+    row(pairs.associate { it.first.name to Value.of(it.second) })
+
+/**
+ * Add a row represented by Meta
+ */
+public fun RowTableBuilder<Value>.row(meta: Meta): Row<Value> {
+    val row = MetaRow(meta)
+    rows.add(row)
+    return row
+}
+
+/**
+ * Shallow copy table to a new [RowTableBuilder]
+ */
+public fun <T> RowTable<T>.toMutableRowTable(): RowTableBuilder<T> =
+    RowTableBuilder(rows.toMutableList(), headers.toMutableList())
+
+/**
+ * Shallow copy and edit [Table] and edit it as [RowTable]
+ */
+public fun <T> Table<T>.withRows(block: RowTableBuilder<T>.() -> Unit): RowTable<T> =
+    RowTableBuilder(rows.toMutableList(), headers.toMutableList()).apply(block)
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/SpreadSheetTable.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/SpreadSheetTable.kt
new file mode 100644
index 00000000..6e83f361
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/SpreadSheetTable.kt
@@ -0,0 +1,76 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.Value
+import kotlin.jvm.JvmName
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+private fun cellId(row: Int, column: String) = "$column[$row]"
+
+
+public data class SpreadSheetCell<T>(val row: Int, val column: String, val value: T) {
+    val id: String get() = cellId(row, column)
+}
+
+public class SpreadSheetTable<T>(
+    private val cellValueType: KType,
+    public val cellMap: MutableMap<String, SpreadSheetCell<T>> = HashMap(),
+    public val columnDefs: MutableMap<String, ColumnHeader<T>> = HashMap(),
+) : MutableTable<T> {
+
+    public val cells: Collection<SpreadSheetCell<T>> get() = cellMap.values
+
+    override fun getOrNull(row: Int, column: String): T? = cellMap[cellId(row, column)]?.value
+
+    public override fun set(row: Int, column: String, value: T?) {
+        val cellId = cellId(row, column)
+        if (value == null) {
+            cellMap.remove(cellId)
+        } else {
+            cellMap[cellId] = SpreadSheetCell(row, column, value)
+        }
+    }
+
+    override val columns: Collection<Column<T>>
+        get() = cells.groupBy(
+            keySelector = { it.column },
+            valueTransform = { it.value }
+        ).entries.map { (key, values) ->
+            val header = columnDefs[key] ?: SimpleColumnHeader(key, cellValueType, Meta.EMPTY)
+            ListColumn(header, values)
+        }
+
+
+    override val rows: List<Row<T>>
+        get() = cells.groupBy { it.row }.map {
+            MapRow(it.value.associate { cell -> cell.column to cell.value })
+        }
+
+    public inline fun <reified R : T> defineColumn(name: String, schemeBuilder: ColumnScheme.() -> Unit) {
+        columnDefs[name] = SimpleColumnHeader(name, typeOf<R>(), ColumnScheme(schemeBuilder).toMeta())
+    }
+}
+
+public inline fun <reified T> SpreadSheetTable(builder: SpreadSheetTable<T>.() -> Unit): SpreadSheetTable<T> =
+    SpreadSheetTable<T>(typeOf<T>()).apply(builder)
+
+@JvmName("setValue")
+public operator fun SpreadSheetTable<Value>.set(row: Int, column: ColumnHeader<Value>, value: Any?) {
+    columnDefs[column.name] = column
+    set(row, column.name, Value.of(value))
+}
+
+/**
+ * Update values and header for given column
+ */
+public operator fun <T> SpreadSheetTable<T>.set(column: ColumnHeader<T>, values: List<T>) {
+    columnDefs[column.name] = column
+    values.forEachIndexed { index, value -> set(index, column, value) }
+}
+
+@JvmName("setValues")
+public operator fun SpreadSheetTable<Value>.set(column: ColumnHeader<Value>, values: List<Any?>) {
+    columnDefs[column.name] = column
+    values.forEachIndexed { index, value -> set(index, column, value) }
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/Table.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/Table.kt
new file mode 100644
index 00000000..cac3cb65
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/Table.kt
@@ -0,0 +1,102 @@
+package space.kscience.tables
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Finite or infinite row set. Rows are produced in a lazy suspendable [Flow].
+ * Each row must contain at least all the fields mentioned in [headers].
+ */
+public interface Rows<out T> {
+    /**
+     * An ordered list of headers that *must* be present.
+     */
+    public val headers: TableHeader<T>
+
+    /**
+     * A lazy sequence of rows.
+     */
+    public fun rowSequence(): Sequence<Row<T>>
+}
+
+/**
+ * A basic abstraction for a table. The abstraction does not specify how the data is stored.
+ */
+public interface Table<out T> : Rows<T> {
+    public fun getOrNull(row: Int, column: String): T?
+    public val columns: Collection<Column<T>>
+    override val headers: TableHeader<T> get() = columns.toList()
+    public val rows: List<Row<T>>
+    override fun rowSequence(): Sequence<Row<T>> = rows.asSequence()
+
+    public companion object
+}
+
+public operator fun <T> Table<T>.get(row: Int, column: String): T =
+    getOrNull(row, column) ?: error("Element with column $column and row $row not found in $this")
+
+public fun <T> Collection<Column<T>>.getOrNull(name: String): Column<T>? = find { it.name == name }
+
+public operator fun <T> Collection<Column<T>>.get(name: String): Column<T> = first { it.name == name }
+
+public inline operator fun <T, reified R : T> Table<T>.get(row: Int, column: ColumnHeader<R>): R? {
+    //require(headers.contains(column)) { "Column $column is not in table headers" }
+    return getOrNull(row, column.name) as? R
+}
+
+public interface Column<out T> : ColumnHeader<T> {
+    public val size: Int
+
+    public fun getOrNull(index: Int): T?
+}
+
+public operator fun <T> Column<T>.get(index: Int): T =
+    getOrNull(index) ?: error("Element with index $index not found in $this")
+
+public inline fun <T> Column<T>.contentEquals(
+    other: Column<T>,
+    criterion: (l: T?, r: T?) -> Boolean = { l, r -> l == r },
+): Boolean = this.indices == other.indices && indices.all { criterion(getOrNull(it), other.getOrNull(it)) }
+
+public val Column<*>.indices: IntRange get() = (0 until size)
+
+public operator fun <T> Column<T>.iterator(): Iterator<T?> = iterator {
+    for (i in indices) {
+        yield(getOrNull(i))
+    }
+}
+
+public fun <T> Column<T>.sequence(): Sequence<T?> = sequence {
+    for (i in indices) {
+        yield(getOrNull(i))
+    }
+}
+
+public fun <T> Column<T>.listValues(): List<T?> = if (this is ListColumn) {
+    this.data
+} else {
+    sequence().toList()
+}
+
+/**
+ * A common abstraction for a row of data. Fields could be accessed by its name
+ */
+public interface Row<out T> {
+    /**
+     * Get a value in [Row] by name or return null if value is missing.
+     *
+     * Note that null value for nullable [T] is indistinguishable from missing value.
+     */
+    public fun getOrNull(column: String): T?
+}
+
+/**
+ * Get a [Row] value or throw error if it is null
+ */
+public operator fun <T: Any> Row<T>.get(column: String): T =
+    getOrNull(column) ?: error("Element with column name $column not found in $this")
+
+/**
+ * Get a value by a column header and cast it if possible to a column type
+ */
+public inline operator fun <T, reified R : T> Row<T>.get(column: ColumnHeader<R>): R = getOrNull(column.name) as? R
+    ?: error("Type conversion to ${R::class} failed for ${getOrNull(column.name)}")
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/TransformationColumn.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/TransformationColumn.kt
new file mode 100644
index 00000000..b06410ca
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/TransformationColumn.kt
@@ -0,0 +1,79 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+/**
+ * A virtual column obtained by transforming Given row to a single value
+ */
+public class TransformationColumn<T, R>(
+    public val table: Table<T>,
+    override val type: KType,
+    override val name: String,
+    override val meta: Meta,
+    public val mapper: (Row<T>) -> R?,
+) : Column<R> {
+    override val size: Int get() = table.rows.size
+
+    override fun getOrNull(index: Int): R? = mapper(table.rows[index])
+}
+
+/**
+ * A virtual column obtained via transformation of a [Row] with caching results on call (evaluation is lazy).
+ *
+ * Calls are not thread safe
+ */
+public class CachedTransformationColumn<T, R>(
+    public val table: Table<T>,
+    override val type: KType,
+    override val name: String,
+    override val meta: Meta,
+    public val mapper: (Row<T>) -> R?,
+) : Column<R> {
+    override val size: Int get() = table.rows.size
+    private val values: HashMap<Int, R?> = HashMap()
+    override fun getOrNull(index: Int): R? = values.getOrPut(index) { mapper(table.rows[index]) }
+}
+
+/**
+ * Create a virtual column from a given column
+ */
+public inline fun <T, reified R> Table<T>.rowsToColumn(
+    name: String,
+    meta: Meta = Meta.EMPTY,
+    cache: Boolean = false,
+    noinline mapper: (Row<T>) -> R?,
+): Column<R> = if (cache) {
+    CachedTransformationColumn(this, typeOf<R>(), name, meta, mapper)
+} else {
+    TransformationColumn(this, typeOf<R>(), name, meta, mapper)
+}
+
+public fun <T, R> Table<T>.rowsToColumn(
+    header: ColumnHeader<R>,
+    cache: Boolean = false,
+    mapper: (Row<T>) -> R?,
+): Column<R> = if (cache) {
+    CachedTransformationColumn(this, header.type, header.name, header.meta, mapper)
+} else {
+    TransformationColumn(this, header.type, header.name, header.meta, mapper)
+}
+
+public fun <T> Table<T>.rowsToDoubleColumn(
+    name: String,
+    meta: Meta = Meta.EMPTY,
+    block: (Row<T>) -> Double,
+): DoubleColumn {
+    val data = DoubleArray(rows.size) { block(rows[it]) }
+    return DoubleColumn(name, data, meta)
+}
+
+public fun <T> Table<T>.rowsToIntColumn(
+    name: String,
+    meta: Meta = Meta.EMPTY,
+    block: (Row<T>) -> Int,
+): IntColumn {
+    val data = IntArray(rows.size) { block(rows[it]) }
+    return IntColumn(name, data, meta)
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/TextRows.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/TextRows.kt
new file mode 100644
index 00000000..9fc0734d
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/TextRows.kt
@@ -0,0 +1,52 @@
+package space.kscience.tables.io
+
+import kotlinx.io.readByteArray
+import space.kscience.dataforge.io.Binary
+import space.kscience.dataforge.meta.Value
+import space.kscience.dataforge.meta.lazyParseValue
+import space.kscience.tables.MapRow
+import space.kscience.tables.Row
+import space.kscience.tables.Rows
+import space.kscience.tables.ValueTableHeader
+
+/**
+ * Read a line as a fixed width [Row]
+ */
+internal fun String.readRow(header: ValueTableHeader, delimiter: Regex): Row<Value> {
+    val values = trim().split(delimiter).map { it.lazyParseValue() }
+
+    if (values.size == header.size) {
+        val map = header.map { it.name }.zip(values).toMap()
+        return MapRow(map)
+    } else {
+        error("Can't read line \"${this}\". Expected ${header.size} values in a line, but found ${values.size}")
+    }
+}
+
+/**
+ * Finite or infinite [Rows] created from a fixed width text binary
+ */
+internal class TextRows(
+    override val headers: ValueTableHeader,
+    private val binary: Binary,
+    private val delimiter: Regex,
+) : Rows<Value> {
+
+    override fun rowSequence(): Sequence<Row<Value>> = binary.read {
+        val text = readByteArray().decodeToString()
+        text.lineSequence()
+            .map { it.trim() }
+            .filter { it.isNotEmpty() }
+            .map { it.readRow(headers, delimiter) }
+//        flow {
+//            do {
+//                val line = readUTF8Line()
+//                if (!line.isNullOrBlank()) {
+//                    val row = readRow(headers, line)
+//                    emit(row)
+//                }
+//            } while (!endOfInput)
+//        }
+    }
+
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/TextTable.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/TextTable.kt
new file mode 100644
index 00000000..753e37f4
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/TextTable.kt
@@ -0,0 +1,77 @@
+package space.kscience.tables.io
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.toList
+import kotlinx.io.readByteArray
+import kotlinx.io.readLine
+import space.kscience.dataforge.io.Binary
+import space.kscience.dataforge.meta.Value
+import space.kscience.tables.*
+
+/**
+ * Finite table created from [Binary] with fixed width text table
+ */
+internal class TextTable(
+    override val headers: ValueTableHeader,
+    private val binary: Binary,
+    val index: List<Int>,
+    val delimiter: Regex = "\\s+".toRegex(),
+) : Table<Value> {
+
+    override val columns: Collection<Column<Value>> get() = headers.map { RowTableColumn(this, it) }
+
+    override val rows: List<Row<Value>> get() = index.map { readAt(it) }
+
+    override fun rowSequence(): Sequence<Row<Value>> = TextRows(headers, binary, delimiter).rowSequence()
+
+    private fun readAt(offset: Int): Row<Value> = binary.read(offset) {
+        val line = readLine() ?: error("Line not found")
+        return@read line.readRow(headers, delimiter)
+    }
+
+    override fun getOrNull(row: Int, column: String): Value? {
+        val offset = index[row]
+        return readAt(offset).getOrNull(column)
+    }
+}
+
+
+/**
+ * A flow of indexes of string start offsets ignoring empty strings
+ */
+private fun Binary.lineIndexFlow(): Flow<Int> = read {
+    //TODO replace by line reader
+    val text = readByteArray().decodeToString()
+    text.lineSequence()
+        .map { it.trim() }
+        .filter { it.isNotEmpty() }
+        .scan(0) { acc, str -> acc + str.length }.asFlow()
+//        var counter: Int = 0
+//        flow {
+//            do {
+//                val line = readUTF8Line()
+//                counter += line?.length ?: 0
+//                if (!line.isNullOrBlank()) {
+//                    emit(counter)
+//                }
+//            } while (!endOfInput)
+//        }
+}
+
+
+/**
+ * Create a row offset index for [TextRows]
+ */
+private suspend fun Binary.buildRowIndex(): List<Int> = lineIndexFlow().toList()
+
+
+/**
+ * Read given binary as TSV [Value] table.
+ * This method does not read the whole table into memory. Instead, it reads it ones and saves line offset index. Then
+ * it reads specific lines on-demand.
+ */
+public suspend fun Binary.readTextTable(header: ValueTableHeader): Table<Value> {
+    val index = buildRowIndex()
+    return TextTable(header, this, index)
+}
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/textTableEnvelope.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/textTableEnvelope.kt
new file mode 100644
index 00000000..3c93aebf
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/textTableEnvelope.kt
@@ -0,0 +1,47 @@
+package space.kscience.tables.io
+
+import space.kscience.dataforge.io.Binary
+import space.kscience.dataforge.io.Envelope
+import space.kscience.dataforge.meta.*
+import space.kscience.dataforge.names.NameToken
+import space.kscience.dataforge.names.asName
+import space.kscience.tables.Rows
+import space.kscience.tables.SimpleColumnHeader
+import space.kscience.tables.Table
+import kotlin.reflect.typeOf
+
+
+/**
+ * Convert given [Table] to a TSV-based envelope, encoding header in Meta
+ */
+public fun Table<Value>.toTextEnvelope(): Envelope = Envelope {
+    meta {
+        headers.forEachIndexed { index, columnHeader ->
+            set(NameToken("column", index.toString()), Meta {
+                "name" put columnHeader.name
+                if (!columnHeader.meta.isEmpty()) {
+                    "meta" put columnHeader.meta
+                }
+            })
+        }
+    }
+
+    type = "table.value"
+    dataID = "valueTable[${this@toTextEnvelope.hashCode()}]"
+
+    data = Binary {
+        writeTextRows(this@toTextEnvelope)
+    }
+}
+
+/**
+ * Read TSV rows from given envelope
+ */
+public fun Envelope.readTextRows(delimiter: Regex = "\\s+".toRegex()): Rows<Value> {
+    val header = meta.getIndexed("column".asName())
+        .entries.sortedBy { it.key?.toInt() }
+        .map { (_, item) ->
+            SimpleColumnHeader<Value>(item["name"].string!!, typeOf<Value>(), item["meta"] ?: Meta.EMPTY)
+        }
+    return TextRows(header, data ?: Binary.EMPTY, delimiter)
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/writeTextRows.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/writeTextRows.kt
new file mode 100644
index 00000000..3db0b398
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/io/writeTextRows.kt
@@ -0,0 +1,59 @@
+package space.kscience.tables.io
+
+import kotlinx.io.Sink
+import kotlinx.io.writeString
+import space.kscience.dataforge.meta.*
+import space.kscience.tables.ColumnHeader
+import space.kscience.tables.Rows
+import space.kscience.tables.get
+import space.kscience.tables.valueType
+
+/**
+ * Write a fixed width value to the output
+ */
+private fun Sink.writeValue(value: Value, width: Int, left: Boolean = true) {
+    require(width > 5) { "Width could not be less than 5" }
+    val str: String = when (value.type) {
+        ValueType.NUMBER -> value.numberOrNull.toString() //TODO apply decimal format
+        ValueType.STRING, ValueType.LIST -> value.string.take(width)
+        ValueType.BOOLEAN -> if (value.boolean) {
+            "true"
+        } else {
+            "false"
+        }
+        ValueType.NULL -> "@null"
+    }
+    val padded = if (left) {
+        str.padEnd(width)
+    } else {
+        str.padStart(width)
+    }
+    writeString(padded)
+}
+
+public val ColumnHeader<Value>.textWidth: Int
+    get() = meta["columnWidth"].int ?: when (valueType) {
+        ValueType.NUMBER -> 8
+        ValueType.STRING -> 16
+        ValueType.BOOLEAN -> 5
+        ValueType.NULL -> 5
+        ValueType.LIST -> 32
+        null -> 16
+    }
+
+/**
+ * Write TSV (or in more general case use [delimiter]) rows without header to the output.
+ */
+public fun Sink.writeTextRows(rows: Rows<Value>, delimiter: String = "\t") {
+    val widths: List<Int> = rows.headers.map {
+        it.textWidth
+    }
+    rows.rowSequence().forEach { row ->
+        rows.headers.forEachIndexed { index, columnHeader ->
+            writeValue(row[columnHeader], widths[index])
+            writeString(delimiter)
+        }
+//        appendLine()
+        writeString("\r\n")
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonMain/kotlin/space/kscience/tables/numericColumns.kt b/tables-kt/src/commonMain/kotlin/space/kscience/tables/numericColumns.kt
new file mode 100644
index 00000000..a14e0ed3
--- /dev/null
+++ b/tables-kt/src/commonMain/kotlin/space/kscience/tables/numericColumns.kt
@@ -0,0 +1,94 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+/**
+ * Optimized primitive-holding column
+ */
+public class DoubleColumn(
+    override val name: String,
+    public val data: DoubleArray,
+    override val meta: Meta = Meta.EMPTY
+) : Column<Double> {
+    override val type: KType get() = typeOf<Double>()
+
+    override val size: Int get() = data.size
+
+    override fun getOrNull(index: Int): Double = data[index]
+
+    /**
+     * Performance-optimized get method
+     */
+    public fun getDouble(index: Int): Double = data[index]
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is DoubleColumn) return false
+
+        if (name != other.name) return false
+        if (!data.contentEquals(other.data)) return false
+        if (meta != other.meta) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = name.hashCode()
+        result = 31 * result + data.contentHashCode()
+        result = 31 * result + meta.hashCode()
+        return result
+    }
+
+    public companion object {
+        public inline operator fun <reified T : Any> invoke(
+            name: String,
+            data: DoubleArray,
+            noinline metaBuilder: ColumnScheme.() -> Unit
+        ): DoubleColumn = DoubleColumn(name, data, ColumnScheme(metaBuilder).toMeta())
+    }
+}
+
+public class IntColumn(
+    override val name: String,
+    public val data: IntArray,
+    override val meta: Meta = Meta.EMPTY
+) : Column<Int> {
+    override val type: KType get() = typeOf<Int>()
+
+    override val size: Int get() = data.size
+
+    override fun getOrNull(index: Int): Int = data[index]
+
+    /**
+     * Performance optimized get method
+     */
+    public fun getInt(index: Int): Int = data[index]
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other !is IntColumn) return false
+
+        if (name != other.name) return false
+        if (!data.contentEquals(other.data)) return false
+        if (meta != other.meta) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = name.hashCode()
+        result = 31 * result + data.contentHashCode()
+        result = 31 * result + meta.hashCode()
+        return result
+    }
+
+    public companion object {
+        public inline operator fun <reified T : Any> invoke(
+            name: String,
+            data: IntArray,
+            noinline metaBuilder: ColumnScheme.() -> Unit
+        ): IntColumn = IntColumn(name, data, ColumnScheme(metaBuilder).toMeta())
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonTest/kotlin/space/kscience/tables/ColumnTableTest.kt b/tables-kt/src/commonTest/kotlin/space/kscience/tables/ColumnTableTest.kt
new file mode 100644
index 00000000..dceb0b84
--- /dev/null
+++ b/tables-kt/src/commonTest/kotlin/space/kscience/tables/ColumnTableTest.kt
@@ -0,0 +1,24 @@
+package space.kscience.tables
+
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class ColumnTableTest {
+    @Test
+    fun columnBuilder() {
+        val columnTable = ColumnTable<Double>(100) {
+            val a by ColumnHeader.typed<Double>()
+            val b by ColumnHeader.typed<Double>()
+
+            // fill column with a new value
+            fill(a) { it.toDouble() }
+            // set column with pre-filled values
+            column(b, List(100) { it.toDouble() })
+            // add a virtual column with values transformed from rows
+            transform("c") { it[a] - it[b] }
+        }
+        assertTrue {
+            columnTable.columns["c"].listValues().all { it == 0.0 }
+        }
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/src/commonTest/kotlin/space/kscience/tables/SpreadSheetTest.kt b/tables-kt/src/commonTest/kotlin/space/kscience/tables/SpreadSheetTest.kt
new file mode 100644
index 00000000..d99bed2d
--- /dev/null
+++ b/tables-kt/src/commonTest/kotlin/space/kscience/tables/SpreadSheetTest.kt
@@ -0,0 +1,26 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Value
+import space.kscience.dataforge.meta.ValueType
+import space.kscience.dataforge.meta.int
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+internal class SpreadSheetTest {
+    @Test
+    fun spreadsheetWriteRead() {
+        val a by ColumnHeader.value(ValueType.STRING)
+        val b by ColumnHeader.value(ValueType.NUMBER)
+        val c by ColumnHeader.value(ValueType.NUMBER)
+
+        val ss = SpreadSheetTable<Value> {
+            set(a, listOf("1", "2", "3"))
+            set(b, listOf(1, 2, 3))
+
+            set(2, c, 22)
+        }
+
+        assertEquals(22, ss[2, c]?.int)
+        assertEquals(6, ss.columns["b"].sequence().sumOf { it?.int ?: 0 })
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/src/jvmMain/kotlin/space/kscience/tables/CastColumn.kt b/tables-kt/src/jvmMain/kotlin/space/kscience/tables/CastColumn.kt
new file mode 100644
index 00000000..57359e75
--- /dev/null
+++ b/tables-kt/src/jvmMain/kotlin/space/kscience/tables/CastColumn.kt
@@ -0,0 +1,37 @@
+package space.kscience.tables
+
+import space.kscience.dataforge.meta.Meta
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+import kotlin.reflect.KType
+import kotlin.reflect.full.isSubtypeOf
+
+@Suppress("UNCHECKED_CAST")
+public fun <T : Any> Column<*>.cast(type: KType): Column<T> {
+    return if (type.isSubtypeOf(this.type)) {
+        this as Column<T>
+    } else {
+        CastColumn(this, type)
+    }
+}
+
+private class CastColumn<T : Any>(private val origin: Column<*>, override val type: KType) : Column<T> {
+    override val name: String get() = origin.name
+    override val meta: Meta get() = origin.meta
+    override val size: Int get() = origin.size
+
+    @Suppress("UNCHECKED_CAST")
+    override fun getOrNull(index: Int): T? = origin.getOrNull(index)?.let {
+        it as T
+    }
+}
+
+public class ColumnProperty<C: Any, T : C>(public val table: Table<C>, public val type: KType) : ReadOnlyProperty<Any?, Column<T>> {
+    override fun getValue(thisRef: Any?, property: KProperty<*>): Column<T> {
+        val name = property.name
+        return (table.columns.getOrNull(name) ?: error("Column with name $name not found in the table")).cast(type)
+    }
+}
+
+public operator fun <C: Any, T : C> Collection<Column<C>>.get(header: ColumnHeader<T>): Column<T>? =
+    find { it.name == header.name }?.cast(header.type)
diff --git a/tables-kt/src/jvmTest/kotlin/space/kscience/tables/io/TextRowsTest.kt b/tables-kt/src/jvmTest/kotlin/space/kscience/tables/io/TextRowsTest.kt
new file mode 100644
index 00000000..86f784a1
--- /dev/null
+++ b/tables-kt/src/jvmTest/kotlin/space/kscience/tables/io/TextRowsTest.kt
@@ -0,0 +1,36 @@
+package space.kscience.tables.io
+
+import kotlinx.coroutines.runBlocking
+import space.kscience.dataforge.io.toByteArray
+import space.kscience.dataforge.meta.Value
+import space.kscience.dataforge.meta.ValueType
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.meta.string
+import space.kscience.dataforge.misc.DFExperimental
+import space.kscience.tables.RowTable
+import space.kscience.tables.column
+import space.kscience.tables.valueRow
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+
+@DFExperimental
+class TextRowsTest {
+    val table = RowTable<Value> {
+        val a by column(ValueType.NUMBER)
+        val b by column(ValueType.STRING)
+        valueRow(a to 1, b to "b1")
+        valueRow(a to 2, b to "b2")
+    }
+
+    @Test
+    fun testTableWriteRead() = runBlocking {
+        val envelope = table.toTextEnvelope()
+        val string = envelope.data!!.toByteArray().decodeToString()
+        println(string)
+        val table = envelope.readTextRows()
+        val rows = table.rowSequence().toList()
+        assertEquals(1, rows[0].getOrNull("a")?.int)
+        assertEquals("b2", rows[1].getOrNull("b")?.string)
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/tables-kt-csv/README.md b/tables-kt/tables-kt-csv/README.md
new file mode 100644
index 00000000..1f75bd52
--- /dev/null
+++ b/tables-kt/tables-kt-csv/README.md
@@ -0,0 +1,21 @@
+# Module tables-kt-csv
+
+
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:tables-kt-csv:0.4.1`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:tables-kt-csv:0.4.1")
+}
+```
diff --git a/tables-kt/tables-kt-csv/build.gradle.kts b/tables-kt/tables-kt-csv/build.gradle.kts
new file mode 100644
index 00000000..40f8b372
--- /dev/null
+++ b/tables-kt/tables-kt-csv/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+kscience {
+    jvm()
+    js()
+    commonMain {
+        api(projects.tablesKt)
+        api(libs.csv)
+    }
+}
+
+readme {
+    maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
+}
\ No newline at end of file
diff --git a/tables-kt/tables-kt-csv/src/commonMain/kotlin/space/kscience/tables/csv/csvString.kt b/tables-kt/tables-kt-csv/src/commonMain/kotlin/space/kscience/tables/csv/csvString.kt
new file mode 100644
index 00000000..975696c3
--- /dev/null
+++ b/tables-kt/tables-kt-csv/src/commonMain/kotlin/space/kscience/tables/csv/csvString.kt
@@ -0,0 +1,37 @@
+package space.kscience.tables.csv
+
+import com.github.doyaaaaaken.kotlincsv.dsl.context.CsvReaderContext
+import com.github.doyaaaaaken.kotlincsv.dsl.context.CsvWriterContext
+import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
+import space.kscience.dataforge.meta.Meta
+import space.kscience.tables.*
+import kotlin.reflect.typeOf
+
+internal fun Map<String, String>.extractHeader(): TableHeader<String> = keys.map {
+    SimpleColumnHeader(it, typeOf<String>(), Meta.EMPTY)
+}
+
+public object CsvFormats {
+    public val tsvReader: CsvReaderContext.() -> Unit = {
+        quoteChar = '"'
+        delimiter = '\t'
+        escapeChar = '\\'
+    }
+
+    public val tsvWriter: CsvWriterContext.() -> Unit = {
+        delimiter = '\t'
+    }
+}
+
+
+public fun Table.Companion.readCsvString(
+    string: String,
+    format: CsvReaderContext.() -> Unit = {},
+): Table<String> {
+    val data = csvReader(format).readAllWithHeader(string)
+    if (data.isEmpty()) error("Can't read empty table")
+    return RowTable(
+        headers = data.first().extractHeader(),
+        data.map { MapRow(it) }
+    )
+}
\ No newline at end of file
diff --git a/tables-kt/tables-kt-csv/src/jvmMain/kotlin/space/kscience/tables/csv/csvFileJvm.kt b/tables-kt/tables-kt-csv/src/jvmMain/kotlin/space/kscience/tables/csv/csvFileJvm.kt
new file mode 100644
index 00000000..fdf005cd
--- /dev/null
+++ b/tables-kt/tables-kt-csv/src/jvmMain/kotlin/space/kscience/tables/csv/csvFileJvm.kt
@@ -0,0 +1,62 @@
+package space.kscience.tables.csv
+
+import com.github.doyaaaaaken.kotlincsv.dsl.context.CsvReaderContext
+import com.github.doyaaaaaken.kotlincsv.dsl.context.CsvWriterContext
+import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
+import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter
+import space.kscience.tables.*
+import java.nio.file.Path
+import kotlin.io.path.inputStream
+import kotlin.io.path.outputStream
+
+public fun Table.Companion.readCsv(
+    path: Path,
+    format: CsvReaderContext.() -> Unit = {},
+): Table<String> {
+    path.inputStream().use { inputStream ->
+        val data = csvReader(format).readAllWithHeader(inputStream)
+        if (data.isEmpty()) error("Can't read empty table")
+        return RowTable(
+            headers = data.first().extractHeader(),
+            data.map { MapRow(it) }
+        )
+    }
+}
+
+public fun Table.Companion.readCsvRows(
+    path: Path,
+    format: CsvReaderContext.() -> Unit = {},
+): Rows<String> {
+    path.inputStream().use { inputStream ->
+        val sequence = csvReader(format).open(inputStream) {
+            readAllWithHeaderAsSequence()
+        }
+        val firstRow = sequence.take(1).first()
+        val header: List<ColumnHeader<String>> = firstRow.extractHeader()
+        return object : Rows<String> {
+            override val headers: TableHeader<String> get() = header
+
+            override fun rowSequence(): Sequence<Row<String>> = sequence {
+                yield(MapRow(firstRow))
+                yieldAll(sequence.map { MapRow(it) })
+            }
+
+        }
+    }
+}
+
+public fun Table.Companion.writeCsvFile(
+    path: Path,
+    table: Table<Any?>,
+    format: CsvWriterContext.() -> Unit = {},
+) {
+    val writer = csvWriter(format)
+    path.outputStream().use { outputStream ->
+        val headerString = table.headers.joinToString(
+            separator = writer.delimiter.toString(),
+            postfix = writer.lineTerminator
+        ) { it.name }
+        outputStream.write(headerString.encodeToByteArray())
+        writer.writeAll(table.rows.map { row -> table.headers.map { row[it] } }, outputStream)
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/tables-kt-csv/src/jvmMain/kotlin/space/kscience/tables/csv/csvStringJvm.kt b/tables-kt/tables-kt-csv/src/jvmMain/kotlin/space/kscience/tables/csv/csvStringJvm.kt
new file mode 100644
index 00000000..c732e5d1
--- /dev/null
+++ b/tables-kt/tables-kt-csv/src/jvmMain/kotlin/space/kscience/tables/csv/csvStringJvm.kt
@@ -0,0 +1,26 @@
+package space.kscience.tables.csv
+
+import com.github.doyaaaaaken.kotlincsv.dsl.context.CsvReaderContext
+import com.github.doyaaaaaken.kotlincsv.dsl.context.CsvWriterContext
+import com.github.doyaaaaaken.kotlincsv.dsl.csvWriter
+import space.kscience.tables.Table
+import space.kscience.tables.get
+import java.net.URL
+
+
+public fun Table.Companion.readCsv(
+    url: URL,
+    format: CsvReaderContext.() -> Unit = {},
+): Table<String> = readCsvString(url.readText(), format)
+
+public fun Table.Companion.writeCsvString(
+    table: Table<Any?>,
+    format: CsvWriterContext.() -> Unit = {},
+): String {
+    val writer = csvWriter(format)
+    val headerString = table.headers.joinToString(
+        separator = writer.delimiter.toString(),
+        postfix = writer.lineTerminator
+    ) { it.name }
+    return headerString + writer.writeAllAsString(table.rows.map { row -> table.headers.map { row[it] } })
+}
diff --git a/tables-kt/tables-kt-csv/src/jvmTest/kotlin/StringReadWrite.kt b/tables-kt/tables-kt-csv/src/jvmTest/kotlin/StringReadWrite.kt
new file mode 100644
index 00000000..a65e1758
--- /dev/null
+++ b/tables-kt/tables-kt-csv/src/jvmTest/kotlin/StringReadWrite.kt
@@ -0,0 +1,36 @@
+package space.kscience.tables.csv
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import space.kscience.dataforge.meta.Value
+import space.kscience.tables.RowTable
+import space.kscience.tables.Table
+import space.kscience.tables.get
+import space.kscience.tables.valueRow
+
+internal class StringReadWrite {
+    val table = RowTable<Value> {
+        val a by column<Value>()
+        val b by column<Value>()
+        valueRow(a to 1, b to "b1")
+        valueRow(a to 2, b to "b2")
+    }
+
+    @Test
+    fun writeRead() {
+        val string = Table.writeCsvString(table)
+        println(string)
+        val reconstructed = Table.readCsvString(string)
+
+        assertEquals("b2", reconstructed[1, "b"])
+    }
+
+    @Test
+    fun writeReadTsv() {
+        val string = Table.writeCsvString(table, CsvFormats.tsvWriter)
+        println(string)
+        val reconstructed = Table.readCsvString(string, CsvFormats.tsvReader)
+
+        assertEquals("b2", reconstructed[1, "b"])
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/tables-kt-dataframe/README.md b/tables-kt/tables-kt-dataframe/README.md
new file mode 100644
index 00000000..8580fde3
--- /dev/null
+++ b/tables-kt/tables-kt-dataframe/README.md
@@ -0,0 +1,21 @@
+# Module tables-kt-dataframe
+
+
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:tables-kt-dataframe:0.4.1`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:tables-kt-dataframe:0.4.1")
+}
+```
diff --git a/tables-kt/tables-kt-dataframe/build.gradle.kts b/tables-kt/tables-kt-dataframe/build.gradle.kts
new file mode 100644
index 00000000..1e6dd0b4
--- /dev/null
+++ b/tables-kt/tables-kt-dataframe/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+    id("space.kscience.gradle.jvm")
+    `maven-publish`
+}
+
+dependencies {
+    api(libs.kotlinx.dataframe)
+    api(projects.tablesKt)
+}
+
+readme {
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
diff --git a/tables-kt/tables-kt-dataframe/src/main/kotlin/space/kscience/dataforge/dataframe/DataFrameAsTable.kt b/tables-kt/tables-kt-dataframe/src/main/kotlin/space/kscience/dataforge/dataframe/DataFrameAsTable.kt
new file mode 100644
index 00000000..dd50c771
--- /dev/null
+++ b/tables-kt/tables-kt-dataframe/src/main/kotlin/space/kscience/dataforge/dataframe/DataFrameAsTable.kt
@@ -0,0 +1,63 @@
+package space.kscience.dataforge.dataframe
+
+import org.jetbrains.kotlinx.dataframe.*
+import org.jetbrains.kotlinx.dataframe.api.cast
+import org.jetbrains.kotlinx.dataframe.api.column
+import org.jetbrains.kotlinx.dataframe.api.getColumn
+import org.jetbrains.kotlinx.dataframe.api.rows
+import space.kscience.dataforge.meta.Meta
+import space.kscience.tables.Column
+import space.kscience.tables.ColumnHeader
+import space.kscience.tables.Row
+import space.kscience.tables.Table
+import kotlin.reflect.KType
+
+@JvmInline
+internal value class DataColumnAsColumn<T>(val column: DataColumn<T>) : Column<T> {
+    override val name: String get() = column.name
+    override val meta: Meta get() = Meta.EMPTY
+    override val type: KType get() = column.type
+    override val size: Int get() = column.size
+
+    override fun getOrNull(index: Int): T = column[index]
+}
+
+internal fun <T> DataColumn<T>.toTableColumn(): Column<T> = if (this is ColumnAsDataColumn) {
+    this.column
+} else {
+    DataColumnAsColumn(this)
+}
+
+@JvmInline
+private value class DataRowAsRow<T>(val row: DataRow<T>) : Row<T> {
+    @Suppress("UNCHECKED_CAST")
+    override fun getOrNull(column: String): T? = row[column] as? T
+}
+
+@JvmInline
+internal value class DataFrameAsTable<T>(private val dataFrame: DataFrame<T>) : Table<T> {
+
+    @Suppress("UNCHECKED_CAST")
+    override fun getOrNull(row: Int, column: String): T? = dataFrame.getColumn(column)[row] as? T
+
+    override val columns: Collection<Column<T>>
+        get() = dataFrame.columns().map { it.cast<T>().toTableColumn() }
+
+    override val rows: List<Row<T>>
+        get() = dataFrame.rows().map { DataRowAsRow(it) }
+}
+
+/**
+ * Represent a [DataFrame] as a [Table]
+ */
+public fun <T> DataFrame<T>.asTable(): Table<T> = DataFrameAsTable(this)
+
+public operator fun <R> DataFrame<*>.get(header: ColumnHeader<R>): DataColumn<R> {
+    val reference = column<R>(header.name)
+    return get(reference)
+}
+
+public operator fun <R> DataRow<*>.get(header: ColumnHeader<R>): R {
+    val reference = column<R>(header.name)
+    return get(reference)
+}
diff --git a/tables-kt/tables-kt-dataframe/src/main/kotlin/space/kscience/dataforge/dataframe/TableAsDataFrame.kt b/tables-kt/tables-kt-dataframe/src/main/kotlin/space/kscience/dataforge/dataframe/TableAsDataFrame.kt
new file mode 100644
index 00000000..15976ff3
--- /dev/null
+++ b/tables-kt/tables-kt-dataframe/src/main/kotlin/space/kscience/dataforge/dataframe/TableAsDataFrame.kt
@@ -0,0 +1,73 @@
+package space.kscience.dataforge.dataframe
+
+import org.jetbrains.kotlinx.dataframe.AnyCol
+import org.jetbrains.kotlinx.dataframe.DataFrame
+import org.jetbrains.kotlinx.dataframe.api.count
+import org.jetbrains.kotlinx.dataframe.api.dataFrameOf
+import org.jetbrains.kotlinx.dataframe.columns.ColumnKind
+import org.jetbrains.kotlinx.dataframe.columns.ValueColumn
+import org.jetbrains.kotlinx.dataframe.indices
+import space.kscience.tables.Table
+import space.kscience.tables.get
+import space.kscience.tables.indices
+import kotlin.reflect.KType
+import space.kscience.tables.Column as TableColumn
+
+internal class ColumnAsDataColumn<T>(
+    val column: TableColumn<T>,
+    val indexList: List<Int> = column.indices.toList(),
+    val nameOverride: String = column.name,
+) : ValueColumn<T> {
+
+    override fun get(indices: Iterable<Int>): ValueColumn<T> {
+        val newIndices = indices.map { indexList[it] }
+        return ColumnAsDataColumn<T>(column, newIndices, nameOverride)
+    }
+
+    override fun get(range: IntRange): ValueColumn<T> {
+        val newIndices = indices.map { indexList[it] }
+        return ColumnAsDataColumn<T>(column, newIndices, nameOverride)
+    }
+
+    override fun rename(newName: String): ValueColumn<T> = ColumnAsDataColumn<T>(column, indexList, newName)
+
+    override fun distinct(): ValueColumn<T> {
+        val newIndices = indexList.distinctBy { column.getOrNull(it) }
+        return ColumnAsDataColumn<T>(column, newIndices, nameOverride)
+    }
+
+    override fun contains(value: T): Boolean = indexList.any { column.getOrNull(it) == value }
+
+    override fun countDistinct(): Int = distinct().count()
+
+    override fun defaultValue(): T? = null
+
+    override fun get(index: Int): T = column[indexList[index]]
+
+    override fun get(columnName: String): AnyCol =
+        if (columnName == nameOverride) this else error("Sub-columns are not allowed")
+
+    override fun kind(): ColumnKind = ColumnKind.Value
+
+    override fun size(): Int = indexList.size
+
+    override fun toSet(): Set<T> = indexList.map { column[it] }.toSet()
+
+    override fun type(): KType = column.type
+
+    override fun values(): Iterable<T> = indexList.asSequence().map { column[it] }.asIterable()
+
+    override fun name(): String = nameOverride
+}
+
+internal fun <T> TableColumn<T>.asDataColumn(): AnyCol = if (this is DataColumnAsColumn) {
+    this.column
+} else {
+    ColumnAsDataColumn(this)
+}
+
+//TODO convert typed value columns to primitive columns
+
+@Suppress("UNCHECKED_CAST")
+public fun <T> Table<T>.toDataFrame(): DataFrame<T> =
+    dataFrameOf(columns.map { it.asDataColumn() }) as DataFrame<T>
diff --git a/tables-kt/tables-kt-dataframe/src/test/kotlin/space/kscience/dataforge/dataframe/DataFrameTableTest.kt b/tables-kt/tables-kt-dataframe/src/test/kotlin/space/kscience/dataforge/dataframe/DataFrameTableTest.kt
new file mode 100644
index 00000000..ab3a5b2b
--- /dev/null
+++ b/tables-kt/tables-kt-dataframe/src/test/kotlin/space/kscience/dataforge/dataframe/DataFrameTableTest.kt
@@ -0,0 +1,49 @@
+package space.kscience.dataforge.dataframe
+
+import org.jetbrains.kotlinx.dataframe.api.add
+import org.jetbrains.kotlinx.dataframe.api.column
+import org.junit.jupiter.api.Test
+import space.kscience.dataforge.misc.DFExperimental
+import space.kscience.tables.*
+import kotlin.math.pow
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(DFExperimental::class)
+internal class DataFrameTableTest {
+
+    @Test
+    fun convertTableToDataFrame() {
+        val x by ColumnHeader.typed<Double>()
+        val x2 by ColumnHeader.typed<Double>()
+        val y by ColumnHeader.typed<Double>()
+
+        val table = ColumnTable<Double?>(100) {
+            //filling column with double values equal to index
+            fill(x) { it.toDouble() }
+            //virtual column filled with x^2
+            transform(x2) { it[x].pow(2) }
+            //Fixed column filled with x^2 + 1
+            column(y, x2.values.map { it?.plus(1) })
+        }
+
+        val dataFrame = table.toDataFrame()
+
+        //println( dataFrame)
+
+        val z by column<Double>()
+
+        val newFrame = dataFrame.add {
+            z.from { it[x] + it[y] + 1.0 }
+        }
+
+        //println(newFrame)
+
+        val newTable = newFrame.asTable()
+
+        assertEquals(newTable.columns[x], table.columns[x])
+        assertTrue {
+            table.rowsToColumn("z") { it[x] + it[y] + 1.0 }.contentEquals(newTable.columns["z"])
+        }
+    }
+}
\ No newline at end of file
diff --git a/tables-kt/tables-kt-exposed/README.md b/tables-kt/tables-kt-exposed/README.md
new file mode 100644
index 00000000..fc0794a2
--- /dev/null
+++ b/tables-kt/tables-kt-exposed/README.md
@@ -0,0 +1,21 @@
+# Module tables-kt-exposed
+
+
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:tables-kt-exposed:0.4.1`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:tables-kt-exposed:0.4.1")
+}
+```
diff --git a/tables-kt/tables-kt-exposed/build.gradle.kts b/tables-kt/tables-kt-exposed/build.gradle.kts
new file mode 100644
index 00000000..853e140b
--- /dev/null
+++ b/tables-kt/tables-kt-exposed/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+    id("space.kscience.gradle.jvm")
+    `maven-publish`
+}
+
+val exposedVersion = "0.47.0"
+
+dependencies {
+    api(projects.tablesKt)
+    api(libs.exposed.core)
+    testImplementation(libs.exposed.jdbc)
+    testImplementation("com.h2database:h2:2.3.232")
+    testImplementation(spclibs.logback.classic)
+}
+
+readme {
+    maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
+}
diff --git a/tables-kt/tables-kt-exposed/src/main/kotlin/space/kscience/dataforge/exposed/ExposedTable.kt b/tables-kt/tables-kt-exposed/src/main/kotlin/space/kscience/dataforge/exposed/ExposedTable.kt
new file mode 100644
index 00000000..311430d2
--- /dev/null
+++ b/tables-kt/tables-kt-exposed/src/main/kotlin/space/kscience/dataforge/exposed/ExposedTable.kt
@@ -0,0 +1,162 @@
+@file:Suppress("FunctionName")
+
+package space.kscience.dataforge.exposed
+
+import org.jetbrains.exposed.dao.id.IntIdTable
+import org.jetbrains.exposed.sql.*
+import org.jetbrains.exposed.sql.transactions.transaction
+import space.kscience.dataforge.meta.Meta
+import space.kscience.tables.Column
+import space.kscience.tables.Row
+import space.kscience.tables.RowTable
+import space.kscience.tables.Table
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+import org.jetbrains.exposed.sql.Column as SqlColumn
+
+/**
+ * Exposed based [Column] implementation.
+ *
+ * @param T The type of table items.
+ * @property db The Exposed database.
+ * @param sqlTable The Exposed table, which must follow the properties defined for [ExposedTable.sqlTable].
+ * @param sqlColumn The Exposed column.
+ * @param type The type of [T].
+ */
+public class ExposedColumn<T : Any>(
+    public val db: Database,
+    public val sqlTable: IntIdTable,
+    public val sqlColumn: SqlColumn<T>,
+    public override val type: KType,
+) : Column<T> {
+    /**
+     * The name of this column.
+     */
+    public override val name: String
+        get() = sqlColumn.name
+
+    /**
+     * Returns [Meta.EMPTY] because it is impossible to store metadata correctly with SQL columns.
+     */
+    public override val meta: Meta
+        get() = Meta.EMPTY
+
+    /**
+     * Returns the count of rows in the table.
+     */
+    public override val size: Int
+        get() = transaction(db) { sqlColumn.table.selectAll().count().toInt() }
+
+    /**
+     * Acquires the value of this column in the row [index].
+     */
+    public override fun getOrNull(index: Int): T? = transaction(db) {
+        sqlTable.selectAll().where { sqlTable.id eq index + 1 }.firstOrNull()?.getOrNull(sqlColumn)
+    }
+}
+
+/**
+ * Exposed based [Row] implementation.
+ *
+ * @param T The type of table items.
+ * @param db The Exposed database.
+ * @param sqlTable The Exposed table, which must follow the properties defined for [ExposedTable.sqlTable].
+ * @param sqlRow The Exposed row.
+ */
+@Suppress("UNCHECKED_CAST")
+public class ExposedRow<T : Any>(
+    public val db: Database,
+    public val sqlTable: IntIdTable,
+    public val sqlRow: ResultRow,
+) : Row<T> {
+
+    /**
+     * Acquires the value of [column] in this row.
+     */
+    public override fun getOrNull(column: String): T? = transaction(db) {
+        val theColumn = sqlTable.columns.find { it.name == column } as SqlColumn<T>? ?: return@transaction null
+        sqlRow.getOrNull(theColumn)
+    }
+}
+
+/**
+ * Exposed based [RowTable] implementation.
+ *
+ * @property db The Exposed database.
+ *
+ * @property sqlTable The Exposed table. It must have the following properties:
+ * 1. Integer `id` column must be present with auto-increment by sequence 1, 2, 3&hellip;
+ * 1. All other columns must be of type [T].
+ *
+ * @property type The type of [T].
+ */
+@Suppress("UNCHECKED_CAST")
+public class ExposedTable<T : Any>(
+    public val db: Database,
+    public val sqlTable: IntIdTable,
+    public val type: KType
+) : Table<T> {
+
+    /**
+     * The list of columns in this table.
+     */
+    public override val columns: List<ExposedColumn<T>> =
+        sqlTable.columns.filterNot { it.name == "id" }.map { ExposedColumn(db, sqlTable, it as SqlColumn<T>, type) }
+
+    /**
+     * The list of rows in this table.
+     */
+    public override val rows: List<ExposedRow<T>>
+        get() = transaction(db) {
+            sqlTable.selectAll().map { ExposedRow(db, sqlTable, it) }
+        }
+
+    public override fun getOrNull(row: Int, column: String): T? = transaction(db) {
+        val sqlColumn: SqlColumn<T> = sqlTable.columns.find { it.name == column } as SqlColumn<T>?
+            ?: return@transaction null
+
+        sqlTable.selectAll().where { sqlTable.id eq row + 1 }.firstOrNull()?.getOrNull(sqlColumn)
+    }
+}
+
+/**
+ * Constructs [ExposedTable].
+ *
+ * @param T The type of table items.
+ * @param db The Exposed database.
+ * @param sqlTable The Exposed table, which must follow the properties defined for [ExposedTable.sqlTable].
+ * @return A new [ExposedTable].
+ */
+public inline fun <reified T : Any> ExposedTable(
+    db: Database,
+    sqlTable: IntIdTable
+): ExposedTable<T> = ExposedTable(db, sqlTable, typeOf<T>())
+
+/**
+ * Constructs [ExposedTable].
+ *
+ * @param T The type of table items.
+ * @param db The Exposed database.
+ * @param tableName The name of table.
+ * @param columns The list of columns' names.
+ * @param sqlColumnType The [IColumnType] for [T].
+ * @return A new [ExposedTable].
+ */
+public inline fun <reified T : Any> ExposedTable(
+    db: Database,
+    tableName: String,
+    columns: List<String>,
+    sqlColumnType: IColumnType<T>,
+): ExposedTable<T> {
+
+    val table = object : IntIdTable(tableName) {
+        init {
+            columns.forEach { registerColumn<T>(it, sqlColumnType) }
+        }
+    }
+
+    transaction(db) {
+        SchemaUtils.createMissingTablesAndColumns(table)
+    }
+    return ExposedTable(db, table)
+}
diff --git a/tables-kt/tables-kt-exposed/src/test/kotlin/space/kscience/dataforge/exposed/ExposedTableTest.kt b/tables-kt/tables-kt-exposed/src/test/kotlin/space/kscience/dataforge/exposed/ExposedTableTest.kt
new file mode 100644
index 00000000..f7f55ac6
--- /dev/null
+++ b/tables-kt/tables-kt-exposed/src/test/kotlin/space/kscience/dataforge/exposed/ExposedTableTest.kt
@@ -0,0 +1,41 @@
+package space.kscience.dataforge.exposed
+
+import org.jetbrains.exposed.sql.Column
+import org.jetbrains.exposed.sql.Database
+import org.jetbrains.exposed.sql.IntegerColumnType
+import org.jetbrains.exposed.sql.insert
+import org.jetbrains.exposed.sql.transactions.transaction
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@Suppress("UNCHECKED_CAST")
+internal class ExposedTableTest {
+
+    @Test
+    fun exposedTable() {
+        val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
+
+        val table = ExposedTable<Int>(
+            db,
+            "test",
+            listOf("a", "b", "c"),
+            IntegerColumnType(),
+        )
+
+        transaction(db) {
+            table.sqlTable.insert {
+                it[table.sqlTable.columns.find { t -> t.name == "a" } as Column<Int>] = 42
+                it[table.sqlTable.columns.find { t -> t.name == "b" } as Column<Int>] = 3
+                it[table.sqlTable.columns.find { t -> t.name == "c" } as Column<Int>] = 7
+            }
+        }
+
+
+        assertEquals(42, table.getOrNull(0, "a"))
+        assertEquals(3, table.getOrNull(0, "b"))
+        assertEquals(7, table.getOrNull(0, "c"))
+        assertEquals(3, table.columns.size)
+        table.columns.forEach { assertEquals(1, it.size) }
+        assertEquals(1, table.rows.size)
+    }
+}
diff --git a/tables-kt/tables-kt-jupyter/README.md b/tables-kt/tables-kt-jupyter/README.md
new file mode 100644
index 00000000..0157d57e
--- /dev/null
+++ b/tables-kt/tables-kt-jupyter/README.md
@@ -0,0 +1,21 @@
+# Module tables-kt-jupyter
+
+
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:tables-kt-jupyter:0.4.1`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:tables-kt-jupyter:0.4.1")
+}
+```
diff --git a/tables-kt/tables-kt-jupyter/api/tables-kt-jupyter.api b/tables-kt/tables-kt-jupyter/api/tables-kt-jupyter.api
new file mode 100644
index 00000000..d4909289
--- /dev/null
+++ b/tables-kt/tables-kt-jupyter/api/tables-kt-jupyter.api
@@ -0,0 +1,5 @@
+public final class space/kscience/tables/TablesForJupyter : org/jetbrains/kotlinx/jupyter/api/libraries/JupyterIntegration {
+	public fun <init> ()V
+	public fun onLoaded (Lorg/jetbrains/kotlinx/jupyter/api/libraries/JupyterIntegration$Builder;)V
+}
+
diff --git a/tables-kt/tables-kt-jupyter/build.gradle.kts b/tables-kt/tables-kt-jupyter/build.gradle.kts
new file mode 100644
index 00000000..dad74cee
--- /dev/null
+++ b/tables-kt/tables-kt-jupyter/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+    id("space.kscience.gradle.jvm")
+    `maven-publish`
+}
+
+dependencies {
+    api(projects.tablesKt)
+    api(spclibs.kotlinx.html)
+}
+
+kscience{
+    jupyterLibrary("space.kscience.tables.TablesForJupyter")
+}
+
+readme {
+    maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
+}
diff --git a/tables-kt/tables-kt-jupyter/src/main/kotlin/TablesForJupyter.kt b/tables-kt/tables-kt-jupyter/src/main/kotlin/TablesForJupyter.kt
new file mode 100644
index 00000000..48e80577
--- /dev/null
+++ b/tables-kt/tables-kt-jupyter/src/main/kotlin/TablesForJupyter.kt
@@ -0,0 +1,96 @@
+package space.kscience.tables
+
+import kotlinx.html.*
+import kotlinx.html.stream.createHTML
+import org.jetbrains.kotlinx.jupyter.api.HTML
+import org.jetbrains.kotlinx.jupyter.api.libraries.JupyterIntegration
+
+private const val MAX_ROWS = 20
+
+
+public class TablesForJupyter : JupyterIntegration() {
+
+    private fun TagConsumer<*>.appendHeaders(headers: TableHeader<*>){
+        tr {
+            classes = classes + "tables-kt-header"
+            headers.forEach { column ->
+                th {
+                    +column.name
+                }
+            }
+        }
+    }
+
+
+    private fun TagConsumer<*>.appendRowValues(headers: TableHeader<*>, row: Row<*>){
+        tr {
+            classes = classes + "tables-kt-row"
+            headers.forEach { column ->
+                td {
+                    +row[column].toString()
+                }
+            }
+        }
+    }
+
+    override fun Builder.onLoaded() {
+        repositories("https://repo.kotlin.link")
+
+        import(
+            "space.kscience.tables.*",
+            "space.kscience.dataforge.meta.*",
+            "space.kscience.dataforge.values.*"
+            //"space.kscience.tables.io.*",
+        )
+
+        //TODO replace by advanced widget
+        render<Table<*>> { table ->
+            HTML(
+                createHTML().table {
+                    classes = classes + "tables-kt-table"
+                    consumer.appendHeaders(table.headers)
+                    table.rows.take(MAX_ROWS).forEach { row ->
+                        consumer.appendRowValues(table.headers, row)
+                    }
+                    if (table.rows.size > MAX_ROWS) {
+                        tr {
+                            td {
+                                +"... Displaying first 20 of ${table.rows.size} rows ..."
+                            }
+                        }
+                    }
+                }
+            )
+        }
+
+        render<Column<*>> { column ->
+            HTML(
+                createHTML().table {
+                    classes = classes + "tables-kt-table"
+                    tr {
+                        classes = classes + "tables-kt-header"
+                        th {
+                            +column.name
+                        }
+                    }
+                    column.sequence().take(MAX_ROWS).forEach { value ->
+                        tr {
+                            classes = classes + "tables-kt-row"
+                            td {
+                                +value.toString()
+                            }
+                        }
+                    }
+                    if (column.size > MAX_ROWS) {
+                        tr {
+                            td {
+                                +"... Displaying first 20 of ${column.size} values ..."
+                            }
+                        }
+                    }
+                }
+            )
+        }
+
+    }
+}
\ No newline at end of file